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
JavaScript
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