From 166eb85509b2409eb5e59f37dea96c849d0df790 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 2 Jun 2026 05:37:16 -0400 Subject: [PATCH] feat(core): Add validate_node_config MCP tool for per-node validation (#31047) Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/code-builder/constants.ts | 5 + .../src/code-builder/index.ts | 1 + .../@n8n/ai-workflow-builder.ee/src/index.ts | 1 + .../mcp/__tests__/validate-node.tool.test.ts | 307 ++++++++++++++++++ packages/cli/src/modules/mcp/mcp.service.ts | 4 + .../mcp/tools/workflow-builder/constants.ts | 1 + .../workflow-builder/mcp-instructions.ts | 11 +- .../workflow-builder/validate-node.tool.ts | 164 ++++++++++ 8 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/modules/mcp/__tests__/validate-node.tool.test.ts create mode 100644 packages/cli/src/modules/mcp/tools/workflow-builder/validate-node.tool.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/constants.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/constants.ts index 886fb721c1c..0f86feaea9b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/constants.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/constants.ts @@ -93,6 +93,11 @@ export const CODE_BUILDER_VALIDATE_TOOL: BuilderToolBase = { displayTitle: 'Validating workflow', }; +export const CODE_BUILDER_VALIDATE_NODE_TOOL: BuilderToolBase = { + toolName: 'validate_node_config', + displayTitle: 'Validating node config', +}; + export const CODE_BUILDER_SEARCH_NODES_TOOL: BuilderToolBase = { toolName: 'search_nodes', displayTitle: 'Searching nodes', diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts index 580f475c2a3..55a93137800 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts @@ -38,6 +38,7 @@ export { CODE_BUILDER_GET_NODE_TYPES_TOOL, CODE_BUILDER_GET_SUGGESTED_NODES_TOOL, CODE_BUILDER_VALIDATE_TOOL, + CODE_BUILDER_VALIDATE_NODE_TOOL, MCP_GET_SDK_REFERENCE_TOOL, MCP_CREATE_WORKFLOW_FROM_CODE_TOOL, MCP_ARCHIVE_WORKFLOW_TOOL, diff --git a/packages/@n8n/ai-workflow-builder.ee/src/index.ts b/packages/@n8n/ai-workflow-builder.ee/src/index.ts index 1af3790ce5e..144e8c53a4e 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/index.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/index.ts @@ -29,6 +29,7 @@ export { CODE_BUILDER_GET_NODE_TYPES_TOOL, CODE_BUILDER_GET_SUGGESTED_NODES_TOOL, CODE_BUILDER_VALIDATE_TOOL, + CODE_BUILDER_VALIDATE_NODE_TOOL, MCP_GET_SDK_REFERENCE_TOOL, MCP_CREATE_WORKFLOW_FROM_CODE_TOOL, MCP_ARCHIVE_WORKFLOW_TOOL, diff --git a/packages/cli/src/modules/mcp/__tests__/validate-node.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/validate-node.tool.test.ts new file mode 100644 index 00000000000..fcd5ec7d710 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/validate-node.tool.test.ts @@ -0,0 +1,307 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import { User } from '@n8n/db'; + +import { createValidateNodeTool } from '../tools/workflow-builder/validate-node.tool'; + +import { Telemetry } from '@/telemetry'; + +const mockValidateNodeConfig = jest.fn(); + +jest.mock('@n8n/workflow-sdk', () => ({ + validateNodeConfig: (...args: unknown[]) => mockValidateNodeConfig(...args), +})); + +jest.mock('@n8n/ai-workflow-builder', () => ({ + CODE_BUILDER_VALIDATE_NODE_TOOL: { + toolName: 'validate_node_config', + displayTitle: 'Validating node config', + }, +})); + +/** Parse the first text content item from a tool result */ +const parseResult = (result: { content: Array<{ type: string; text?: string }> }) => + JSON.parse((result.content[0] as { type: 'text'; text: string }).text) as Record; + +describe('validate-node MCP tool', () => { + const user = Object.assign(new User(), { id: 'user-1' }); + let telemetry: Telemetry; + + beforeEach(() => { + jest.clearAllMocks(); + telemetry = mockInstance(Telemetry, { track: jest.fn() }); + }); + + const createTool = () => createValidateNodeTool(user, telemetry); + + describe('smoke tests', () => { + test('creates tool with correct name and read-only annotations', () => { + const tool = createTool(); + + expect(tool.name).toBe('validate_node_config'); + expect(tool.config.annotations).toEqual( + expect.objectContaining({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }), + ); + expect(typeof tool.handler).toBe('function'); + }); + }); + + describe('handler tests', () => { + test('returns valid=true for a single valid node', async () => { + mockValidateNodeConfig.mockReturnValue({ valid: true, errors: [] }); + + const tool = createTool(); + const result = await tool.handler( + { + nodes: [ + { + type: 'n8n-nodes-base.set', + typeVersion: 3, + parameters: { mode: 'manual', assignments: { assignments: [] } }, + }, + ], + }, + {} as never, + ); + + const response = parseResult(result); + expect(response.valid).toBe(true); + expect(response.results).toEqual([ + { + index: 0, + type: 'n8n-nodes-base.set', + valid: true, + }, + ]); + expect(result.isError).toBeUndefined(); + }); + + test('returns structured errors for invalid node', async () => { + mockValidateNodeConfig.mockReturnValue({ + valid: false, + errors: [{ path: 'mode', message: 'Invalid value: expected "manual" or "raw"' }], + }); + + const tool = createTool(); + const result = await tool.handler( + { + nodes: [ + { + type: 'n8n-nodes-base.set', + typeVersion: 3, + parameters: { mode: 'bogus' }, + }, + ], + }, + {} as never, + ); + + const response = parseResult(result); + expect(response.valid).toBe(false); + expect(response.results).toEqual([ + { + index: 0, + type: 'n8n-nodes-base.set', + valid: false, + errors: [{ path: 'mode', message: 'Invalid value: expected "manual" or "raw"' }], + }, + ]); + }); + + test('mixes valid and invalid nodes — top-level valid is false', async () => { + mockValidateNodeConfig.mockReturnValueOnce({ valid: true, errors: [] }).mockReturnValueOnce({ + valid: false, + errors: [{ path: 'url', message: 'URL is required' }], + }); + + const tool = createTool(); + const result = await tool.handler( + { + nodes: [ + { name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 3, parameters: {} }, + { name: 'HTTP', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, parameters: {} }, + ], + }, + {} as never, + ); + + const response = parseResult(result); + expect(response.valid).toBe(false); + const results = response.results as Array>; + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + index: 0, + name: 'Set', + type: 'n8n-nodes-base.set', + valid: true, + }); + expect(results[1]).toEqual({ + index: 1, + name: 'HTTP', + type: 'n8n-nodes-base.httpRequest', + valid: false, + errors: [{ path: 'url', message: 'URL is required' }], + }); + }); + + test('forwards typeVersion and parameters as received from Zod-parsed input', async () => { + // At runtime the MCP framework Zod-parses input before calling the handler, + // so `.default(1)` for typeVersion and `.default({})` for parameters are + // already applied. This test verifies the handler forwards them unchanged. + mockValidateNodeConfig.mockReturnValue({ valid: true, errors: [] }); + + const tool = createTool(); + await tool.handler( + { + nodes: [{ type: 'n8n-nodes-base.noOp', typeVersion: 1, parameters: {} }], + }, + {} as never, + ); + + expect(mockValidateNodeConfig).toHaveBeenCalledWith( + 'n8n-nodes-base.noOp', + 1, + { parameters: {}, subnodes: undefined }, + { isToolNode: undefined }, + ); + }); + + test('forwards isToolNode to validateNodeConfig options', async () => { + mockValidateNodeConfig.mockReturnValue({ valid: true, errors: [] }); + + const tool = createTool(); + await tool.handler( + { + nodes: [ + { + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4, + parameters: { url: 'https://example.com' }, + isToolNode: true, + }, + ], + }, + {} as never, + ); + + expect(mockValidateNodeConfig).toHaveBeenCalledWith( + 'n8n-nodes-base.httpRequest', + 4, + { parameters: { url: 'https://example.com' }, subnodes: undefined }, + { isToolNode: true }, + ); + }); + + test('forwards subnodes inside the config object', async () => { + mockValidateNodeConfig.mockReturnValue({ valid: true, errors: [] }); + const subnodes = { + model: { type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', version: 1 }, + }; + + const tool = createTool(); + await tool.handler( + { + nodes: [ + { + type: '@n8n/n8n-nodes-langchain.agent', + typeVersion: 1, + parameters: { agent: 'conversationalAgent' }, + subnodes, + }, + ], + }, + {} as never, + ); + + expect(mockValidateNodeConfig).toHaveBeenCalledWith( + '@n8n/n8n-nodes-langchain.agent', + 1, + { parameters: { agent: 'conversationalAgent' }, subnodes }, + { isToolNode: undefined }, + ); + }); + + test('passes through graceful-fallback result for unknown node types', async () => { + // validateNodeConfig returns valid:true when no schema is registered + mockValidateNodeConfig.mockReturnValue({ valid: true, errors: [] }); + + const tool = createTool(); + const result = await tool.handler( + { + nodes: [ + { type: 'community-node-without-schema', typeVersion: 1, parameters: { foo: 'bar' } }, + ], + }, + {} as never, + ); + + const response = parseResult(result); + expect(response.valid).toBe(true); + expect((response.results as Array<{ valid: boolean }>)[0].valid).toBe(true); + }); + + test('tracks telemetry on success with nodeCount, invalidCount, and errorCount', async () => { + mockValidateNodeConfig.mockReturnValueOnce({ valid: true, errors: [] }).mockReturnValueOnce({ + valid: false, + errors: [ + { path: 'a', message: 'bad a' }, + { path: 'b', message: 'bad b' }, + ], + }); + + const tool = createTool(); + await tool.handler( + { + nodes: [ + { type: 'n8n-nodes-base.set', typeVersion: 3, parameters: {} }, + { type: 'n8n-nodes-base.httpRequest', typeVersion: 4, parameters: {} }, + ], + }, + {} as never, + ); + + expect(telemetry.track).toHaveBeenCalledWith( + 'User called mcp tool', + expect.objectContaining({ + user_id: 'user-1', + tool_name: 'validate_node_config', + parameters: expect.objectContaining({ nodeCount: 2 }), + results: expect.objectContaining({ + success: true, + data: expect.objectContaining({ invalidCount: 1, errorCount: 2 }), + }), + }), + ); + }); + + test('tracks telemetry on failure when validateNodeConfig throws', async () => { + mockValidateNodeConfig.mockImplementation(() => { + throw new Error('schema load failed'); + }); + + const tool = createTool(); + const result = await tool.handler( + { + nodes: [{ type: 'n8n-nodes-base.set', typeVersion: 3, parameters: {} }], + }, + {} as never, + ); + + expect(result.isError).toBe(true); + expect(telemetry.track).toHaveBeenCalledWith( + 'User called mcp tool', + expect.objectContaining({ + tool_name: 'validate_node_config', + results: expect.objectContaining({ + success: false, + error: 'schema load failed', + }), + }), + ); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index c5377868fbb..9ade2a08c96 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -51,6 +51,7 @@ import { createGetWorkflowSdkReferenceTool } from './tools/workflow-builder/get- 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 { NodeCatalogService } from '@/node-catalog'; @@ -379,6 +380,9 @@ export class McpService { const validateTool = createValidateWorkflowCodeTool(user, this.telemetry, this.nodeTypes); server.registerTool(validateTool.name, validateTool.config, validateTool.handler); + const validateNodeTool = createValidateNodeTool(user, this.telemetry); + server.registerTool(validateNodeTool.name, validateNodeTool.config, validateNodeTool.handler); + const createTool = createCreateWorkflowFromCodeTool( user, this.workflowCreationService, diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/constants.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/constants.ts index 98c6db38e96..dd913d56079 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/constants.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/constants.ts @@ -7,6 +7,7 @@ export { CODE_BUILDER_GET_NODE_TYPES_TOOL, CODE_BUILDER_GET_SUGGESTED_NODES_TOOL, CODE_BUILDER_VALIDATE_TOOL, + CODE_BUILDER_VALIDATE_NODE_TOOL, MCP_GET_SDK_REFERENCE_TOOL, MCP_CREATE_WORKFLOW_FROM_CODE_TOOL, MCP_ARCHIVE_WORKFLOW_TOOL, 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 99f307cdd3c..b1ff96870a8 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 @@ -15,6 +15,7 @@ import { CODE_BUILDER_GET_SUGGESTED_NODES_TOOL, CODE_BUILDER_SEARCH_NODES_TOOL, CODE_BUILDER_VALIDATE_TOOL, + CODE_BUILDER_VALIDATE_NODE_TOOL, } from './constants'; export function getMcpInstructions(isBuilderEnabled: boolean): string { @@ -34,13 +35,15 @@ To build n8n workflows, follow these steps in order: 5. Write the workflow code using the SDK patterns from the reference and the exact parameter names from the type definitions. Follow the coding guidelines and design guidance sections of the SDK reference (retrieve them with ${MCP_GET_SDK_REFERENCE_TOOL.toolName} using sections "guidelines" and "design"). -6. Validate: Call ${CODE_BUILDER_VALIDATE_TOOL.toolName} with your full code. Fix any errors and re-validate until valid. +6. Spot-check as you go: after configuring each node, call ${CODE_BUILDER_VALIDATE_NODE_TOOL.toolName} on it before wiring it into the rest of the workflow. Catches param, type, and discriminator errors per-node with a clean signal, before they're buried inside a full-graph ${CODE_BUILDER_VALIDATE_TOOL.toolName} run. Can check several candidate configs in one call so you wire only the one that passes. -7. 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) summarizing what the workflow does — this helps users find and understand their workflows. +7. Validate: Call ${CODE_BUILDER_VALIDATE_TOOL.toolName} with your full code. Fix any errors and re-validate until valid. -8. 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). 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. +8. 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) summarizing what the workflow does — this helps users find and understand their workflows. -9. Archive: Call ${MCP_ARCHIVE_WORKFLOW_TOOL.toolName} with the workflow ID.`; +9. 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). 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. Archive: Call ${MCP_ARCHIVE_WORKFLOW_TOOL.toolName} with the workflow ID.`; return isBuilderEnabled ? `${INTRO}\n\n${BUILDER_INSTRUCTIONS}` : INTRO; } diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/validate-node.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/validate-node.tool.ts new file mode 100644 index 00000000000..24cb38c0423 --- /dev/null +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/validate-node.tool.ts @@ -0,0 +1,164 @@ +import type { User } from '@n8n/db'; +import z from 'zod'; + +import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; +import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; + +import { CODE_BUILDER_VALIDATE_NODE_TOOL } from './constants'; + +import type { Telemetry } from '@/telemetry'; + +const nodeInputSchema = z.object({ + name: z + .string() + .optional() + .describe('Optional node name. Echoed back in the result so callers can correlate.'), + type: z + .string() + .describe('Full node type, e.g. "n8n-nodes-base.set" or "@n8n/n8n-nodes-langchain.agent".'), + typeVersion: z.number().positive().default(1).describe('Node type version. Defaults to 1.'), + parameters: z + .record(z.unknown()) + .default({}) + .describe('Node parameters object — same shape as workflow JSON.'), + subnodes: z + .unknown() + .optional() + .describe( + 'Optional subnode config for AI parent nodes (e.g. langchain agent): `{ model, memory, tools: [...] }` of `{ type, version }` refs.', + ), + isToolNode: z + .boolean() + .optional() + .describe( + 'Set to true when validating a node that is wired as an AI tool subnode (ai_tool connection). Adjusts which displayOptions branch is evaluated.', + ), +}); + +const inputSchema = { + nodes: z + .array(nodeInputSchema) + .min(1) + .max(50) + .describe('One or more node configurations to validate independently.'), +} satisfies z.ZodRawShape; + +const outputSchema = { + valid: z.boolean().describe('True when every node is valid.'), + results: z + .array( + z.object({ + index: z.number().describe('Position of this node in the input array.'), + name: z.string().optional().describe('Echo of the input name, if provided.'), + type: z.string().describe('Echo of the input node type.'), + valid: z.boolean().describe('Whether this node config is valid.'), + errors: z + .array( + z.object({ + path: z.string().describe('Parameter path of the error.'), + message: z.string().describe('Human-readable error message.'), + }), + ) + .optional() + .describe('Validation errors for this node (omitted when valid).'), + }), + ) + .describe('Per-node validation results, in input order.'), + error: z + .string() + .optional() + .describe( + 'Top-level error message when validation could not run at all (e.g. internal failure loading the schema). Omitted on success.', + ), +} satisfies z.ZodRawShape; + +/** + * MCP tool that validates individual node configurations against their + * generated Zod schema. Schema-level only — does not check workflow-level + * concerns (connections, triggers, disconnected nodes, credential existence). + * For those, use `validate_workflow` on full workflow code. + */ +export const createValidateNodeTool = ( + user: User, + telemetry: Telemetry, +): ToolDefinition => ({ + name: CODE_BUILDER_VALIDATE_NODE_TOOL.toolName, + config: { + description: + "Validate a node's config the moment you write it — before assembling create_workflow_from_code or calling update_workflow. Read-only and needs no existing workflow, so use it freely while composing. Unlike the write tools (which validate only as they mutate), this returns isolated per-node, per-parameter errors with no graph noise, and can check several candidate configs in one call so you wire only the one that passes. For langchain tool subnodes (nodes wired via ai_tool), set isToolNode: true so the schema evaluates the correct displayOptions branch. Schema-level only — for connections, required inputs, triggers, and credentials use validate_workflow.", + inputSchema, + outputSchema, + annotations: { + title: CODE_BUILDER_VALIDATE_NODE_TOOL.displayTitle, + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + handler: async ({ nodes }) => { + const telemetryPayload: UserCalledMCPToolEventPayload = { + user_id: user.id, + tool_name: CODE_BUILDER_VALIDATE_NODE_TOOL.toolName, + parameters: { nodeCount: nodes.length }, + }; + + try { + const { validateNodeConfig } = await import('@n8n/workflow-sdk'); + + const results = nodes.map((node, index) => { + const result = validateNodeConfig( + node.type, + node.typeVersion, + { parameters: node.parameters, subnodes: node.subnodes }, + { isToolNode: node.isToolNode }, + ); + + return { + index, + ...(node.name !== undefined ? { name: node.name } : {}), + type: node.type, + valid: result.valid, + ...(result.valid ? {} : { errors: result.errors }), + }; + }); + + const valid = results.every((r) => r.valid); + const invalidCount = results.filter((r) => !r.valid).length; + const errorCount = results.reduce((sum, r) => sum + (r.errors?.length ?? 0), 0); + + telemetryPayload.results = { + success: true, + data: { invalidCount, errorCount }, + }; + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); + + const response = { valid, results }; + + return { + content: [{ type: 'text', text: JSON.stringify(response, null, 2) }], + structuredContent: response, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + telemetryPayload.results = { + success: false, + error: errorMessage, + }; + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); + + const output = { + valid: false, + results: [], + error: errorMessage, + }; + + return { + content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], + structuredContent: output, + isError: true, + }; + } + }, +});