n8n_lint_workflow
Lint an n8n workflow JSON to catch missing credentials, deprecated node types, broken connections, and other common issues.
Instructions
Lint an n8n workflow JSON. Returns concrete errors and warnings: missing credentials, deprecated node types (Function -> Code, spreadsheetFile -> convertToFile/extractFromFile), broken connections, missing or non-numeric typeVersion, duplicate node names or IDs, AI Agent missing ai_languageModel sub-node, Webhook missing webhookId, IF node still on v1 condition schema.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| workflow | Yes | n8n workflow as either a parsed object or a JSON string. |
Implementation Reference
- src/tools/lint-workflow.ts:32-257 (handler)The main lintWorkflow function that executes the tool logic. It parses the input workflow, validates nodes (missing names/IDs, duplicates, deprecated types, missing typeVersion, credentials, AI agent connections, webhook IDs, IF v1 schema), checks connections for broken references, and returns formatted issues.
export async function lintWorkflow(rawArgs: unknown) { const args = inputZod.parse(rawArgs); const workflow = typeof args.workflow === "string" ? safeParse(args.workflow) : args.workflow; const issues: Issue[] = []; if (!workflow || typeof workflow !== "object" || Array.isArray(workflow)) { issues.push({ severity: "error", message: "Workflow is not a JSON object.", }); return formatResult(issues); } const wf = workflow as Record<string, unknown>; const nodes = wf.nodes; if (!Array.isArray(nodes)) { issues.push({ severity: "error", message: "Workflow has no `nodes` array.", }); return formatResult(issues); } const connections = wf.connections && typeof wf.connections === "object" ? (wf.connections as Record<string, unknown>) : {}; const nodeNames = new Set<string>(); const seenIds = new Set<string>(); const incomingByType: Record<string, Map<string, number>> = {}; for (const [src, conf] of Object.entries(connections)) { if (!conf || typeof conf !== "object") continue; for (const [connType, branches] of Object.entries( conf as Record<string, unknown>, )) { if (!Array.isArray(branches)) continue; for (const branch of branches) { if (!Array.isArray(branch)) continue; for (const c of branch) { if (!c || typeof c !== "object") continue; const target = (c as Record<string, unknown>).node; if (typeof target !== "string") continue; const map = (incomingByType[connType] ??= new Map()); map.set(target, (map.get(target) ?? 0) + 1); } } } // keep src referenced void src; } for (const raw of nodes) { if (!raw || typeof raw !== "object") { issues.push({ severity: "error", message: "Node is not an object." }); continue; } const n = raw as Record<string, unknown>; const nodeName = typeof n.name === "string" ? n.name : undefined; if (!nodeName) { issues.push({ severity: "error", message: "Node missing string `name`." }); } else { if (nodeNames.has(nodeName)) { issues.push({ severity: "error", node: nodeName, message: "Duplicate node name.", }); } nodeNames.add(nodeName); } if (typeof n.id !== "string") { issues.push({ severity: "error", node: nodeName, message: "Node missing string `id`.", }); } else { if (seenIds.has(n.id)) { issues.push({ severity: "error", node: nodeName, message: `Duplicate node id ${n.id}.`, }); } seenIds.add(n.id); } const nodeType = typeof n.type === "string" ? n.type : undefined; if (!nodeType) { issues.push({ severity: "error", node: nodeName, message: "Node missing string `type`.", }); } else if (DEPRECATED_NODE_TYPES[nodeType]) { issues.push({ severity: "warning", node: nodeName, message: `Node type "${nodeType}" is deprecated. Use "${DEPRECATED_NODE_TYPES[nodeType]}".`, }); } if (n.typeVersion === undefined || n.typeVersion === null) { issues.push({ severity: "error", node: nodeName, message: "Missing `typeVersion`.", }); } else if (typeof n.typeVersion !== "number") { issues.push({ severity: "error", node: nodeName, message: "`typeVersion` must be a number.", }); } if ( !Array.isArray(n.position) || n.position.length !== 2 || n.position.some((v) => typeof v !== "number") ) { issues.push({ severity: "warning", node: nodeName, message: "`position` should be a [x, y] array of numbers.", }); } if (nodeType && CREDENTIAL_REQUIRED_TYPES.has(nodeType) && !n.credentials) { issues.push({ severity: "warning", node: nodeName, message: `Node type "${nodeType}" usually needs a credential. None set.`, }); } if (nodeType && AI_AGENT_TYPES.has(nodeType) && nodeName) { const lm = incomingByType["ai_languageModel"]?.get(nodeName) ?? 0; if (lm === 0) { issues.push({ severity: "error", node: nodeName, message: "AI Agent has no `ai_languageModel` sub-node connected. Attach a chat model (e.g. lmChatOpenAi).", }); } } if (nodeType && WEBHOOK_TYPES.has(nodeType) && !n.webhookId) { issues.push({ severity: "warning", node: nodeName, message: "Webhook node has no `webhookId`. n8n auto-generates one on import, so the production URL will change. Set `webhookId` to keep a stable URL.", }); } if (nodeType && IF_NODE_TYPES.has(nodeType)) { const params = n.parameters && typeof n.parameters === "object" ? (n.parameters as Record<string, unknown>) : {}; const conditions = params.conditions as Record<string, unknown> | undefined; const looksLikeV1 = conditions !== undefined && typeof conditions === "object" && !Array.isArray(conditions) && ("boolean" in conditions || "string" in conditions || "number" in conditions || "dateTime" in conditions); if (looksLikeV1) { issues.push({ severity: "warning", node: nodeName, message: "IF node uses v1 condition schema (`conditions.boolean[]` etc.). Bump `typeVersion` to 2+ and switch to the v2 condition shape (`conditions.options`, `conditions.conditions[]`, `conditions.combinator`).", }); } } } for (const [src, conf] of Object.entries(connections)) { if (!nodeNames.has(src)) { issues.push({ severity: "error", message: `Connection from unknown node "${src}".`, }); continue; } const main = (conf as { main?: unknown })?.main; if (!Array.isArray(main)) continue; for (const branch of main) { if (!Array.isArray(branch)) continue; for (const conn of branch) { if ( !conn || typeof conn !== "object" || typeof (conn as Record<string, unknown>).node !== "string" ) { issues.push({ severity: "error", message: `Malformed connection from "${src}".`, }); continue; } const target = (conn as { node: string }).node; if (!nodeNames.has(target)) { issues.push({ severity: "error", message: `Connection from "${src}" points to missing node "${target}".`, }); } } } } return formatResult(issues); } - src/tools/lint-workflow.ts:10-20 (schema)Input schema for n8n_lint_workflow: accepts a 'workflow' property that can be either a parsed object or a JSON string.
export const lintWorkflowInputSchema = { type: "object", properties: { workflow: { description: "n8n workflow as either a parsed object or a JSON string.", oneOf: [{ type: "object" }, { type: "string" }], }, }, required: ["workflow"], } as const; - src/index.ts:52-57 (registration)Registration of the n8n_lint_workflow tool in the tool list, with description and inputSchema reference.
{ name: "n8n_lint_workflow", description: "Lint an n8n workflow JSON. Returns concrete errors and warnings: missing credentials, deprecated node types (Function -> Code, spreadsheetFile -> convertToFile/extractFromFile), broken connections, missing or non-numeric typeVersion, duplicate node names or IDs, AI Agent missing ai_languageModel sub-node, Webhook missing webhookId, IF node still on v1 condition schema.", inputSchema: lintWorkflowInputSchema, }, - src/index.ts:115-116 (registration)Handler dispatch case for n8n_lint_workflow in the CallToolRequestSchema handler, calling lintWorkflow.
case "n8n_lint_workflow": return lintWorkflow(args ?? {}); - src/schemas/node-catalog.ts:1-54 (helper)Node catalog constants used by lintWorkflow: DEPRECATED_NODE_TYPES, AI_AGENT_TYPES, WEBHOOK_TYPES, IF_NODE_TYPES, CREDENTIAL_REQUIRED_TYPES.
/** * Catalog of n8n node behaviors used by the lint and generate tools. * * The lists here are intentionally narrow: they cover the most common nodes * a user is likely to ship in a workflow. Unknown node types are treated as * valid by the linter (no false positives). */ export const DEPRECATED_NODE_TYPES: Record<string, string> = { "n8n-nodes-base.function": "n8n-nodes-base.code", "n8n-nodes-base.functionItem": "n8n-nodes-base.code", "n8n-nodes-base.start": "n8n-nodes-base.manualTrigger", "n8n-nodes-base.spreadsheetFile": "n8n-nodes-base.convertToFile or n8n-nodes-base.extractFromFile", }; /** * AI agent root types. Both prefixes appear in the wild: the older * `n8n-nodes-langchain.*` and the canonical `@n8n/n8n-nodes-langchain.*`. */ export const AI_AGENT_TYPES = new Set<string>([ "n8n-nodes-langchain.agent", "@n8n/n8n-nodes-langchain.agent", ]); export const WEBHOOK_TYPES = new Set<string>([ "n8n-nodes-base.webhook", ]); export const IF_NODE_TYPES = new Set<string>([ "n8n-nodes-base.if", ]); export const CREDENTIAL_REQUIRED_TYPES = new Set<string>([ "n8n-nodes-base.airtable", "n8n-nodes-base.discord", "n8n-nodes-base.gmail", "n8n-nodes-base.googleSheets", "n8n-nodes-base.notion", "n8n-nodes-base.openAi", "n8n-nodes-base.postgres", "n8n-nodes-base.slack", "n8n-nodes-base.stripe", ]); export const KNOWN_TRIGGER_TYPES = new Set<string>([ "n8n-nodes-base.manualTrigger", "n8n-nodes-base.webhook", "n8n-nodes-base.scheduleTrigger", "n8n-nodes-base.cron", "n8n-nodes-base.rssFeedReadTrigger", "n8n-nodes-base.emailReadImap", ]);