UNPKG

n8n

Version:

n8n Workflow Automation Tool

509 lines 28.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.createUpdateWorkflowTool = void 0; const db_1 = require("@n8n/db"); const permissions_1 = require("@n8n/permissions"); const n8n_workflow_1 = require("n8n-workflow"); const zod_1 = __importDefault(require("zod")); const mcp_constants_1 = require("../../mcp.constants"); const connection_structure_check_1 = require("./connection-structure-check"); const constants_1 = require("./constants"); const credential_validation_1 = require("./credential-validation"); const credentials_auto_assign_1 = require("./credentials-auto-assign"); const data_table_validation_1 = require("./data-table-validation"); const skills_used_1 = require("./skills-used"); const version_metadata_1 = require("./version-metadata"); const workflow_operations_1 = require("./workflow-operations"); const subworkflow_policy_denial_error_1 = require("../../../../errors/subworkflow-policy-denial.error"); const workflow_helpers_1 = require("../../../../workflow-helpers"); const workflow_validation_utils_1 = require("../workflow-validation.utils"); const MAX_OPERATIONS_PER_CALL = 100; const baseOperationTypes = [ 'updateNodeParameters', 'setNodeParameter', 'addNode', 'removeNode', 'renameNode', 'addConnection', 'removeConnection', 'setNodeCredential', 'setNodePosition', 'setNodeDisabled', 'setNodeSettings', 'setWorkflowMetadata', 'setWorkflowSettings', 'addTags', 'removeTags', 'setNodeGroups', ]; const gatedGroupOperationTypes = ['addNodeGroup', 'removeNodeGroup', 'updateNodeGroup']; const GATED_GROUP_OP_TYPES = new Set(gatedGroupOperationTypes); const buildOperationTypeSchema = (canvasGroupsEnabled) => canvasGroupsEnabled ? zod_1.default.enum([...baseOperationTypes, ...gatedGroupOperationTypes]) : zod_1.default.enum(baseOperationTypes); const positionInputSchema = zod_1.default.array(zod_1.default.number()).length(2).describe('Canvas [x, y].'); const credentialsInputSchema = zod_1.default.record(zod_1.default.string(), zod_1.default.object({ id: zod_1.default.string().optional(), name: zod_1.default.string() })); const nodeInputSchema = zod_1.default.object({ name: zod_1.default.string().describe('Unique node name.'), type: zod_1.default.string().describe('Node type, e.g. "n8n-nodes-base.set".'), typeVersion: zod_1.default.number(), parameters: zod_1.default.record(zod_1.default.string(), zod_1.default.unknown()).optional(), position: positionInputSchema.optional(), credentials: credentialsInputSchema.optional(), disabled: zod_1.default.boolean().optional(), notes: zod_1.default.string().optional(), id: zod_1.default.string().optional(), }); const nodeSettingsInputSchema = zod_1.default.object({ onError: zod_1.default .enum(['stopWorkflow', 'continueRegularOutput', 'continueErrorOutput']) .optional() .describe('Error behavior.'), retryOnFail: zod_1.default.boolean().optional(), maxTries: zod_1.default.number().int().min(2).max(5).optional(), waitBetweenTries: zod_1.default.number().int().min(0).max(5000).optional(), alwaysOutputData: zod_1.default.boolean().optional(), executeOnce: zod_1.default.boolean().optional(), }); const combinedSettingsInputSchema = zod_1.default .object({ ...nodeSettingsInputSchema.shape, ...workflow_operations_1.workflowSettingsObjectSchema.shape, }) .describe('Settings to write. For setNodeSettings use the node-level keys (onError, retryOnFail, maxTries, waitBetweenTries, alwaysOutputData, executeOnce). For setWorkflowSettings use the workflow-level keys (errorWorkflow, timezone, executionOrder, saveExecutionProgress, saveManualExecutions, saveDataErrorExecution, saveDataSuccessExecution, executionTimeout, timeSavedPerExecution, callerPolicy, callerIds). Provide only the keys for the operation you are running.'); const buildOperationInputSchema = (canvasGroupsEnabled) => zod_1.default .object({ type: buildOperationTypeSchema(canvasGroupsEnabled).describe('Operation type.'), nodeName: zod_1.default.string().optional().describe('For node-targeted ops.'), node: nodeInputSchema.optional().describe('For addNode.'), parameters: zod_1.default .record(zod_1.default.string(), zod_1.default.unknown()) .optional() .describe('For updateNodeParameters.'), replace: zod_1.default.boolean().optional().describe('For updateNodeParameters; default false.'), path: zod_1.default.string().min(2).optional().describe('For setNodeParameter; JSON Pointer path.'), value: zod_1.default.unknown().optional().describe('For setNodeParameter.'), oldName: zod_1.default.string().optional().describe('For renameNode.'), newName: zod_1.default .string() .optional() .describe(canvasGroupsEnabled ? 'For renameNode or updateNodeGroup.' : 'For renameNode.'), source: zod_1.default.string().optional().describe('For connection ops.'), target: zod_1.default.string().optional().describe('For connection ops.'), sourceIndex: zod_1.default .number() .int() .nonnegative() .optional() .describe('For connection ops; default 0.'), targetIndex: zod_1.default .number() .int() .nonnegative() .optional() .describe('For connection ops; default 0.'), connectionType: zod_1.default.string().optional().describe('For connection ops; default "main".'), credentialKey: zod_1.default.string().optional().describe('For setNodeCredential.'), credentialId: zod_1.default.string().optional().describe('For setNodeCredential.'), credentialName: zod_1.default.string().optional().describe('For setNodeCredential.'), position: positionInputSchema.optional().describe('For setNodePosition.'), disabled: zod_1.default.boolean().optional().describe('For setNodeDisabled.'), settings: combinedSettingsInputSchema .optional() .describe('For setNodeSettings or setWorkflowSettings.'), name: zod_1.default .string() .max(128) .optional() .describe(canvasGroupsEnabled ? 'For setWorkflowMetadata (workflow name) or addNodeGroup (group name).' : 'Only used for setWorkflowMetadata.'), description: zod_1.default .string() .max(255) .optional() .describe(canvasGroupsEnabled ? 'For setWorkflowMetadata, addNodeGroup, or updateNodeGroup.' : 'Only used for setWorkflowMetadata.'), names: zod_1.default.array(zod_1.default.string()).optional().describe('For addTags / removeTags.'), nodeGroups: zod_1.default .array(zod_1.default.object({ id: zod_1.default.string().optional(), name: zod_1.default.string(), nodeNames: zod_1.default.array(zod_1.default.string()), description: zod_1.default.string().optional(), })) .optional() .describe('For setNodeGroups. Replaces all node groups; pass [] to clear. Group members are node names, not ids.'), ...(canvasGroupsEnabled ? { groupName: zod_1.default.string().optional().describe('For removeNodeGroup / updateNodeGroup.'), nodeNames: zod_1.default .array(zod_1.default.string()) .optional() .describe('For addNodeGroup / updateNodeGroup; group member node names.'), id: zod_1.default.string().optional().describe('For addNodeGroup; group id, generated if omitted.'), } : {}), }) .describe('Workflow update operation. Provide fields matching type.'); const strictOperationsSchema = zod_1.default.array(workflow_operations_1.partialUpdateOperationSchema); function parseStrictOperations(operations) { const parsed = strictOperationsSchema.safeParse(operations); if (parsed.success) return parsed.data; const details = parsed.error.issues .map(({ path, message }) => { const [index, ...rest] = path; if (typeof index === 'number') { return `operation ${index}${rest.length ? `.${rest.join('.')}` : ''}: ${message}`; } return `${path.length ? path.join('.') : 'operations'}: ${message}`; }) .join('; '); throw new Error(`Invalid operations: ${details}`); } function collectTouchedNodes(operations) { const touched = new Map(); const recordTouch = (name, opIndex) => { if (!touched.has(name)) touched.set(name, opIndex); }; for (let i = 0; i < operations.length; i++) { const op = operations[i]; if (op.type === 'addNode') { recordTouch(op.node.name, i); } else if (op.type === 'updateNodeParameters' || op.type === 'setNodeParameter') { recordTouch(op.nodeName, i); } else if (op.type === 'renameNode') { const idx = touched.get(op.oldName); if (idx !== undefined) { touched.delete(op.oldName); touched.set(op.newName, idx); } } else if (op.type === 'removeNode') { touched.delete(op.nodeName); } } return touched; } const buildInputSchema = (canvasGroupsEnabled) => ({ workflowId: zod_1.default.string().describe('The ID of the workflow to update.'), skillsUsed: zod_1.default.array(zod_1.default.string()).optional().describe(skills_used_1.SKILLS_USED_PARAM_DESCRIPTION), operations: zod_1.default .array(buildOperationInputSchema(canvasGroupsEnabled)) .min(1) .max(MAX_OPERATIONS_PER_CALL) .describe(`Ordered operations to apply atomically (max ${MAX_OPERATIONS_PER_CALL}). If any op fails, nothing is saved.`), versionName: version_metadata_1.versionNameInputSchema.describe('Short summary of what this update changes, shown in the workflow\'s version history (e.g. "Added Slack notification after HTTP request"). Always provide it.'), versionDescription: version_metadata_1.versionDescriptionInputSchema.describe('Longer description of what changed and why, shown in the version history alongside the version name.'), }); const outputSchema = { workflowId: zod_1.default.string().optional(), name: zod_1.default.string().optional(), nodeCount: zod_1.default.number().optional(), url: zod_1.default.string().optional(), appliedOperations: zod_1.default.number().optional().describe('Number of operations applied.'), autoAssignedCredentials: zod_1.default .array(zod_1.default.object({ nodeName: zod_1.default.string(), credentialName: zod_1.default.string(), credentialType: zod_1.default.string(), source: zod_1.default.enum(['user', 'aiGateway']).optional(), })) .optional() .describe('Credentials auto-assigned to nodes that were added in this update.'), validationWarnings: zod_1.default .array(zod_1.default.object({ code: zod_1.default.string(), message: zod_1.default.string(), nodeName: zod_1.default.string().optional(), })) .optional() .describe('Graph and JSON validation warnings on the resulting workflow. Use these to self-correct on the next call.'), note: zod_1.default.string().optional(), settings: zod_1.default .record(zod_1.default.string(), zod_1.default.unknown()) .optional() .describe('Resulting workflow-level settings after the update. Present only when a setWorkflowSettings operation ran. Reflects server-side cleanup (e.g. "DEFAULT" values are removed).'), error: zod_1.default .string() .optional() .describe('Error message explaining why the update failed. Present only on failure.'), }; async function assertErrorWorkflowIsUsable({ errorWorkflowId, parentWorkflowId, user, workflowFinderService, workflowPublishedDataService, useWorkflowPublicationService, nodeTypes, subworkflowPolicyChecker, errorTriggerType, }) { if (!errorWorkflowId || errorWorkflowId === 'DEFAULT') return; const errorWorkflow = await workflowFinderService.findWorkflowForUser(errorWorkflowId, user, ['workflow:read'], { includeActiveVersion: !useWorkflowPublicationService }); if (!errorWorkflow) { throw new Error(`Error workflow '${errorWorkflowId}' was not found or you do not have access to it. Find a valid workflow ID with search_workflows, or create an error-handler workflow first.`); } let publishedNodes; if (useWorkflowPublicationService) { const published = await workflowPublishedDataService.getPublishedWorkflowData(errorWorkflowId); publishedNodes = published?.publishedVersion.nodes; } else if (errorWorkflow.activeVersionId && errorWorkflow.activeVersion) { publishedNodes = errorWorkflow.activeVersion.nodes ?? []; } if (!publishedNodes) { throw new Error(`Error workflow '${errorWorkflow.name}' (${errorWorkflowId}) has no published version, so n8n cannot run it when this workflow fails. Publish that workflow first (publish_workflow), then set it as the error workflow.`); } const hasErrorTrigger = publishedNodes.some((node) => node.type === errorTriggerType && node.disabled !== true); if (!hasErrorTrigger) { throw new Error(`The published version of workflow '${errorWorkflow.name}' (${errorWorkflowId}) has no active Error Trigger node, so it would never run when this workflow fails. Add an Error Trigger node (${errorTriggerType}) and publish it, pick a different error workflow, or create a new error-handler workflow.`); } const errorWorkflowInstance = new n8n_workflow_1.Workflow({ id: errorWorkflow.id, name: errorWorkflow.name, nodeTypes, nodes: [], connections: {}, active: false, settings: errorWorkflow.settings ?? {}, }); try { await subworkflowPolicyChecker.check(errorWorkflowInstance, parentWorkflowId, undefined, user.id); } catch (error) { if (error instanceof subworkflow_policy_denial_error_1.SubworkflowPolicyDenialError) { throw new Error(`Error workflow '${errorWorkflow.name}' (${errorWorkflowId}) cannot be called by this workflow because of its caller policy, so n8n would block it at runtime. Update that workflow's settings ("This workflow can be called by …") to allow this one — set it to any workflow, or add this workflow to its allowlist — or pick a different error workflow.`); } throw error; } } function assertCallerPolicyConsistent(settings) { if (settings?.callerPolicy !== 'workflowsFromAList') return; const callerIds = (settings.callerIds ?? '') .split(',') .map((id) => id.trim()) .filter((id) => id.length > 0); if (callerIds.length === 0) { throw new Error('callerPolicy "workflowsFromAList" requires callerIds — a comma-separated list of workflow IDs allowed to call this workflow. Without it, no workflow can call this one. Provide callerIds, or choose a different callerPolicy.'); } } function assertExecutionTimeoutWithinMax(executionTimeout, maxTimeout) { if (executionTimeout === undefined || executionTimeout <= 0 || maxTimeout <= 0) return; if (executionTimeout > maxTimeout) { throw new Error(`executionTimeout (${executionTimeout}s) exceeds this instance's maximum of ${maxTimeout}s. Set executionTimeout to ${maxTimeout} or less.`); } } const createUpdateWorkflowTool = (user, workflowFinderService, workflowService, urlService, telemetry, nodeTypes, credentialsService, sharedWorkflowRepository, collaborationService, dataTableOps, tagService, globalConfig, subworkflowPolicyChecker, workflowPublishedDataService, aiGatewayService, options = {}) => ({ name: constants_1.MCP_UPDATE_WORKFLOW_TOOL.toolName, config: { description: 'Atomically update an existing workflow with operation objects. Edits nodes/connections and also workflow-level settings via setWorkflowSettings — including the error workflow that runs automatically on failure to send alerts (e.g. when a user asks to "add error handling" or "notify me if this breaks"). Pass skillsUsed if n8n skills were used.', inputSchema: buildInputSchema(options.canvasGroupsEnabled === true), outputSchema, annotations: { title: constants_1.MCP_UPDATE_WORKFLOW_TOOL.displayTitle, readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, }, handler: async ({ workflowId, skillsUsed, operations, versionName, versionDescription, }) => { const sanitizedSkillsUsed = (0, skills_used_1.sanitizeSkillsUsed)(skillsUsed); const telemetryPayload = { user_id: user.id, tool_name: constants_1.MCP_UPDATE_WORKFLOW_TOOL.toolName, parameters: { workflowId, ...(sanitizedSkillsUsed !== undefined ? { skillsUsed: sanitizedSkillsUsed } : {}), opCount: operations.length, opTypes: operations.map((op) => op.type), hasVersionName: !!versionName, hasVersionDescription: !!versionDescription, }, }; try { const strictOperations = parseStrictOperations(operations); const hasGatedGroupOperations = strictOperations.some((op) => GATED_GROUP_OP_TYPES.has(op.type)); if (hasGatedGroupOperations && options.canvasGroupsEnabled !== true) { throw new Error('Node group operations (addNodeGroup, removeNodeGroup, updateNodeGroup) are not available on this instance.'); } const hasTagOperations = strictOperations.some((op) => op.type === 'addTags' || op.type === 'removeTags'); if (hasTagOperations && globalConfig.tags.disabled) { throw new Error('Tag operations are not supported on this instance because tags are disabled.'); } const existingWorkflow = await (0, workflow_validation_utils_1.getMcpWorkflow)(workflowId, user, ['workflow:update'], workflowFinderService, { includeTags: hasTagOperations }); await collaborationService.ensureWorkflowEditable(existingWorkflow.id); const result = (0, workflow_operations_1.applyOperations)((0, workflow_operations_1.toWorkflowSlice)(existingWorkflow, { includeTags: hasTagOperations }), strictOperations); if (!result.success) { throw new Error(result.error); } const credentialCheck = await (0, credential_validation_1.validateCredentialReferences)(strictOperations, existingWorkflow, user, credentialsService, nodeTypes, { workflowId: existingWorkflow.id }); if (!credentialCheck.ok) { throw new Error(credentialCheck.error); } const invalidToolSourceResponse = (0, connection_structure_check_1.buildInvalidAiToolSourceErrorResponse)({ nodes: result.workflow.nodes, connections: result.workflow.connections }, nodeTypes, (errorMessage) => ({ error: errorMessage }), telemetryPayload, telemetry); if (invalidToolSourceResponse) return invalidToolSourceResponse; const { projectId: workflowProjectId } = await sharedWorkflowRepository.findOneOrFail({ where: { workflowId, role: 'workflow:owner' }, select: ['projectId'], }); const dataTableCheck = await (0, data_table_validation_1.validateDataTableReferencesForUpdate)(result.workflow.nodes, collectTouchedNodes(strictOperations), workflowProjectId, dataTableOps); if (!dataTableCheck.ok) { throw new Error(dataTableCheck.error); } const setsErrorWorkflow = strictOperations.some((op) => op.type === 'setWorkflowSettings' && op.settings.errorWorkflow !== undefined); if (setsErrorWorkflow) { await assertErrorWorkflowIsUsable({ errorWorkflowId: result.workflow.settings?.errorWorkflow, parentWorkflowId: workflowId, user, workflowFinderService, workflowPublishedDataService, useWorkflowPublicationService: globalConfig.workflows.useWorkflowPublicationService, nodeTypes, subworkflowPolicyChecker, errorTriggerType: globalConfig.nodes.errorTriggerType, }); } const setsCallerConfig = strictOperations.some((op) => op.type === 'setWorkflowSettings' && (op.settings.callerPolicy !== undefined || op.settings.callerIds !== undefined)); if (setsCallerConfig) { assertCallerPolicyConsistent(result.workflow.settings); } const setsExecutionTimeout = strictOperations.some((op) => op.type === 'setWorkflowSettings' && op.settings.executionTimeout !== undefined); if (setsExecutionTimeout) { assertExecutionTimeoutWithinMax(result.workflow.settings?.executionTimeout, globalConfig.executions.maxTimeout); } const hasNonTagOperations = strictOperations.some((op) => op.type !== 'addTags' && op.type !== 'removeTags'); const hasSettingsOperations = strictOperations.some((op) => op.type === 'setWorkflowSettings'); if (hasSettingsOperations && existingWorkflow.activeVersionId && !(0, permissions_1.hasGlobalScope)(user, 'workflow:publish')) { const canPublish = await workflowFinderService.findWorkflowHeadForUser(workflowId, user, [ 'workflow:publish', ]); if (!canPublish) { throw new Error('Changing settings on a published workflow reactivates it, which requires publish permission. Your account can edit but not publish this workflow. Ask the owner for publish access, or unpublish the workflow first.'); } } const workflowUpdateData = new db_1.WorkflowEntity(); Object.assign(workflowUpdateData, { name: result.workflow.name, ...(result.workflow.description !== undefined ? { description: result.workflow.description } : {}), nodes: result.workflow.nodes, connections: result.workflow.connections, ...(hasSettingsOperations ? { settings: result.workflow.settings } : {}), ...(result.nodeGroupsChanged ? { nodeGroups: result.workflow.nodeGroups } : {}), meta: hasNonTagOperations ? { ...(existingWorkflow.meta ?? {}), aiBuilderAssisted: true, builderVariant: 'mcp', } : (existingWorkflow.meta ?? {}), }); (0, workflow_helpers_1.resolveNodeWebhookIds)(workflowUpdateData, nodeTypes); let credentialAssignments = []; let skippedHttpNodes = []; let autoAssignOutcomes = []; if (result.addedNodeNames.length > 0) { const addedNodeSet = new Set(result.addedNodeNames); const addedNodes = workflowUpdateData.nodes.filter((n) => addedNodeSet.has(n.name)); const autoAssign = await (0, credentials_auto_assign_1.autoPopulateNodeCredentials)({ ...workflowUpdateData, nodes: addedNodes }, user, nodeTypes, credentialsService, workflowProjectId, aiGatewayService); credentialAssignments = autoAssign.assignments; skippedHttpNodes = autoAssign.skippedHttpNodes; autoAssignOutcomes = autoAssign.outcomes; } const { ParseValidateHandler } = await import('@n8n/ai-workflow-builder'); const validator = new ParseValidateHandler({ generatePinData: false, nodeTypesProvider: nodeTypes, }); const validationWarnings = validator.validateJSON({ name: workflowUpdateData.name, nodes: workflowUpdateData.nodes, connections: workflowUpdateData.connections, }); let tagIds; if (result.tagNames !== undefined) { if ((0, permissions_1.hasGlobalScope)(user, 'tag:create')) { const resolvedTags = await tagService.findOrCreateByNames(result.tagNames); tagIds = resolvedTags.map((t) => t.id); } else { const resolvedTags = await tagService.findByNames(result.tagNames); const resolvedNames = new Set(resolvedTags.map((t) => t.name)); const missing = result.tagNames .map((n) => n.trim()) .filter((name) => name.length > 0 && !resolvedNames.has(name)); if (missing.length > 0) { throw new Error(`Cannot apply the following tags because they don't exist and your account does not have permission to create them: ${missing.join(', ')}`); } tagIds = resolvedTags.map((t) => t.id); } } const versionMetadata = (0, version_metadata_1.resolveVersionMetadata)({ versionName, versionDescription }, (0, version_metadata_1.buildUpdateVersionMetadata)({ nodes: existingWorkflow.nodes, connections: existingWorkflow.connections }, { nodes: workflowUpdateData.nodes, connections: workflowUpdateData.connections })); const updatedWorkflow = await workflowService.update(user, workflowUpdateData, workflowId, { aiBuilderAssisted: hasNonTagOperations, source: 'n8n-mcp', versionName: versionMetadata.name, versionDescription: versionMetadata.description, ...(tagIds !== undefined ? { tagIds } : {}), }); if (autoAssignOutcomes.length > 0) { const nodeTypesByName = new Map(updatedWorkflow.nodes.map((n) => [n.name, n.type])); (0, credentials_auto_assign_1.trackAutoassignOutcomes)(telemetry, user.id, 'update_workflow', autoAssignOutcomes, nodeTypesByName, workflowId); } void collaborationService.broadcastWorkflowUpdate(workflowId, user.id).catch(() => { }); const baseUrl = urlService.getInstanceBaseUrl(); const workflowUrl = `${baseUrl}/workflow/${updatedWorkflow.id}`; telemetryPayload.results = { success: true, data: { workflowId: updatedWorkflow.id, nodeCount: updatedWorkflow.nodes.length, }, }; telemetry.track(mcp_constants_1.USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); const output = { workflowId: updatedWorkflow.id, name: updatedWorkflow.name, nodeCount: updatedWorkflow.nodes.length, url: workflowUrl, appliedOperations: strictOperations.length, autoAssignedCredentials: credentialAssignments, validationWarnings, note: skippedHttpNodes.length ? `HTTP Request nodes (${skippedHttpNodes.join(', ')}) were skipped during credential auto-assignment. Their credentials must be configured manually.` : undefined, settings: hasSettingsOperations ? (updatedWorkflow.settings ?? {}) : undefined, }; return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], structuredContent: output, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); telemetryPayload.results = { success: false, error: errorMessage, }; telemetry.track(mcp_constants_1.USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); const output = { error: errorMessage }; return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }], structuredContent: output, isError: true, }; } }, }); exports.createUpdateWorkflowTool = createUpdateWorkflowTool; //# sourceMappingURL=update-workflow.tool.js.map