UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

261 lines • 12 kB
import { getObjectInfo } from "../comfyui/client.js"; import { analyzeGraphHealth } from "./workflow-health.js"; import { convertUiToApi, isUiFormat } from "./workflow-converter.js"; /** * Validate a workflow without executing it. * Checks for missing nodes, broken connections, type mismatches, and missing models. * * @param workflow The workflow to validate (API format, or a UI-format graph with * nodes[]/links[] — converted via convertUiToApi so saved canvas exports can be * checked without a prior strip). * @param options.health Merge graph-health heuristics (disconnected nodes, duplicate * model loads, orphaned branches, muted/bypassed, partial denoise on an empty * latent) as info/warning issues plus a * structured `health` section. Health findings NEVER flip `valid`. Default true. */ export async function validateWorkflow(workflow, options = {}) { const includeHealth = options.health ?? true; const issues = []; // 1. Fetch available node definitions let objectInfo; try { objectInfo = await getObjectInfo(); } catch (err) { return { valid: false, issues: [ { severity: "error", node_id: "", node_type: "", message: `Cannot connect to ComfyUI to validate: ${err instanceof Error ? err.message : err}`, }, ], summary: "Validation failed: cannot reach ComfyUI", }; } // Saved UI exports pair widgets_values against object_info. Convert first so // create_workflow (action:"validate") on a canvas JSON hits the same alignment // as /prompt (#1869). let graph = workflow; if (isUiFormat(workflow)) { const converted = convertUiToApi(workflow, objectInfo); graph = converted.workflow; // A dropped unknown node is promoted to an authoritative error below. Drop // its conversion warning by IDENTITY so the same node is not reported twice // — API-format validation reports it once, and these two paths must agree. const promoted = new Set(converted.missingNodeTypes.map((m) => m.warning)); for (const message of converted.warnings) { if (promoted.has(message)) continue; issues.push({ severity: "warning", node_id: "", node_type: "", message, }); } // The converter DROPS a node whose type object_info doesn't know, so step 2a // below can never see it. Without this the same uninstalled custom node is an // ERROR in API format and a mere warning in UI format — and the UI graph // reports `valid: true`, which is a false green on the one thing validation // exists to catch. for (const { nodeId, classType } of converted.missingNodeTypes) { issues.push({ severity: "error", node_id: nodeId, node_type: classType, kind: "missing_node_type", message: `Unknown node type "${classType}". This node may not be installed.`, }); } } const nodeIds = Object.keys(graph); // 2. Check each node for (const nodeId of nodeIds) { const node = graph[nodeId]; const classType = node.class_type; // 2a. Check node type exists const nodeDef = objectInfo[classType]; if (!nodeDef) { issues.push({ severity: "error", node_id: nodeId, node_type: classType, kind: "missing_node_type", message: `Unknown node type "${classType}". This node may not be installed.`, }); continue; // Can't validate inputs without the definition } // 2b. Check required inputs are present const requiredInputs = nodeDef.input?.required ?? {}; for (const [inputName, inputSpec] of Object.entries(requiredInputs)) { const inputType = Array.isArray(inputSpec) ? inputSpec[0] : undefined; const hasAutogrowChildren = inputType === "COMFY_AUTOGROW_V3" && Object.keys(node.inputs).some((name) => name.startsWith(`${inputName}.`)); if (!(inputName in node.inputs) && !hasAutogrowChildren) { issues.push({ severity: "error", node_id: nodeId, node_type: classType, message: `Missing required input "${inputName}"`, }); } } // 2c. Check connections point to existing nodes for (const [inputName, value] of Object.entries(node.inputs)) { if (Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "number") { const [sourceId, outputIndex] = value; if (!graph[sourceId]) { issues.push({ severity: "error", node_id: nodeId, node_type: classType, message: `Input "${inputName}" references node "${sourceId}" which doesn't exist in the workflow`, }); continue; } // Check output index is valid const sourceNode = graph[sourceId]; const sourceDef = objectInfo[sourceNode.class_type]; if (sourceDef && sourceDef.output) { if (outputIndex >= sourceDef.output.length) { issues.push({ severity: "error", node_id: nodeId, node_type: classType, message: `Input "${inputName}" references output index ${outputIndex} of node "${sourceId}" (${sourceNode.class_type}), but it only has ${sourceDef.output.length} outputs`, }); } } } } // 2d. Check every combo (dropdown) value against objectInfo's valid options checkComboValues(nodeId, classType, node.inputs, issues, objectInfo); } // 3. Check for cycles (basic: no self-references) for (const nodeId of nodeIds) { const node = graph[nodeId]; for (const [inputName, value] of Object.entries(node.inputs)) { if (Array.isArray(value) && value.length === 2 && value[0] === nodeId) { issues.push({ severity: "error", node_id: nodeId, node_type: node.class_type, message: `Self-referencing connection on input "${inputName}"`, }); } } } // 4. Check for output nodes const hasOutput = nodeIds.some((id) => { const ct = graph[id].class_type; return (ct === "SaveImage" || ct === "PreviewImage" || ct === "SaveAnimatedWEBP" || ct === "SaveAnimatedPNG" || objectInfo[ct]?.output_node === true); }); if (!hasOutput) { issues.push({ severity: "warning", node_id: "", node_type: "", message: "Workflow has no output node (SaveImage, PreviewImage, etc.). Nothing will be generated.", }); } // 5. Graph-health heuristics (merged as info/warning issues — never flip `valid`). let health; if (includeHealth) { health = analyzeGraphHealth(graph, objectInfo); for (const f of health.findings) { // Authoritative missing_required_input is already reported as an error above // (step 2b) for installed nodes — don't double-report. The heuristic variant // (uninstalled node) is NOT covered by the validator, so it adds value. if (f.kind === "missing_required_input" && !f.heuristic) continue; issues.push({ severity: f.severity, node_id: f.node_ids[0] ?? "", node_type: f.node_type ?? "", message: f.detail, kind: f.kind, health: true, }); } } const errors = issues.filter((i) => i.severity === "error"); const warnings = issues.filter((i) => i.severity === "warning"); const valid = errors.length === 0; const summary = valid ? warnings.length > 0 ? `Workflow is valid with ${warnings.length} warning(s)` : "Workflow is valid" : `Workflow has ${errors.length} error(s) and ${warnings.length} warning(s)`; return { valid, issues, summary, health }; } /** * Check that every combo (dropdown) widget value is one ComfyUI actually offers — * mirroring ComfyUI's OWN frontend validator, which rejects an out-of-list value * with `value_not_in_list` (the "N ERRORS" the user sees in the error panel). This * catches BOTH classes the frontend flags as errors: * - missing models (a `*.safetensors`/`*.gguf`/… value not in the loader's list, * incl. the empty-list case when nothing is installed), and * - any other invalid dropdown value (a wrong `type`, `sampler_name`, `scheduler`, * an uploaded-file name that isn't present, etc.). * Uses /object_info's authoritative option lists (all model search paths, subdirs, * and custom loaders — no hardcoded mappings). A value fed by a CONNECTION (not a * literal widget value) is skipped: it can't be validated statically. */ function checkComboValues(nodeId, classType, inputs, issues, objectInfo) { const nodeDef = objectInfo[classType]; if (!nodeDef) return; const allInputDefs = { ...nodeDef.input?.required, ...nodeDef.input?.optional, }; for (const [inputName, inputSpec] of Object.entries(allInputDefs)) { // A combo input's spec is `[ [...options], {config?} ]`. Non-combos (INT/FLOAT/ // STRING/BOOLEAN, or a linked node-type string) have a non-array first element. if (!Array.isArray(inputSpec) || !Array.isArray(inputSpec[0])) continue; const value = inputs[inputName]; // Only a literal widget value is checkable; a connection is `[nodeId, outIdx]`. if (typeof value !== "string") continue; const validValues = inputSpec[0]; // Only validate string option lists (model names, types, samplers, schedulers…). // A non-empty non-string/mixed list (rare) is skipped to avoid false positives; // an EMPTY list is validated (that's the "nothing installed" → not in [] case). if (validValues.length > 0 && !validValues.every((v) => typeof v === "string")) continue; if (validValues.includes(value)) continue; const isModel = /\.(safetensors|gguf|ckpt|pt|pth|bin|sft)$/i.test(value); const hint = isModel ? " This model file isn't installed here (or ComfyUI needs a restart to see it) — download it or fix the path/filename." : validValues.length ? ` Valid options: ${validValues .slice(0, 8) .map((v) => `"${v}"`) .join(", ")}${validValues.length > 8 ? ", …" : ""}.` : ` No options are available for "${inputName}" on this ComfyUI (the source list is empty).`; issues.push({ severity: "error", node_id: nodeId, node_type: classType, kind: isModel ? "missing_model" : "value_not_in_list", input: inputName, value, message: `"${inputName}" = "${value}" is not in the list of valid options (value_not_in_list).${hint}`, }); } } //# sourceMappingURL=workflow-validator.js.map