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),
166 lines • 7.97 kB
JavaScript
import { comfyApiFetch } from "../comfyui/client.js";
import { bodyPrefixOf, describeStatus } from "../comfyui/json-guard.js";
import { diffLocks, generateLock, parseWorkflowLock, } from "../services/workflow-lock.js";
import { ValidationError } from "../utils/errors.js";
async function loadWorkflowFromLibrary(filename) {
const encoded = encodeURIComponent(`workflows/${filename}`);
const res = await comfyApiFetch(`/api/userdata/${encoded}`);
if (!res.ok) {
// Gate the ABSENCE wording on 404, the way fetchImage does (#385 review
// finding 4). This branch was dead until #385 made it reachable, so it had
// never had to distinguish "the server says it is not there" from "an auth
// gate, a 502, or a proxy that does not forward /api/userdata answered".
// Reporting the second as the first is the #796 fold, and an agent told its
// workflow does not exist is one step from recreating or overwriting it.
throw new ValidationError(res.status === 404
? `Workflow not found in user library: ${filename} (404)`
: `Could NOT read "${filename}" from the user library: the server answered ${describeStatus(res.status, res.statusText)}. That is not a report that it is missing — nothing was read.`);
}
return (await res.json());
}
/**
* Load a workflow's lock from the user library. Returns null when no lock is
* present — either a 404, or (seen on some builds) a 200 with an empty body.
* Only a genuinely malformed JSON body throws.
*/
async function loadLockFromLibrary(filename) {
const lockName = `${filename}.lock.json`;
const encoded = encodeURIComponent(`workflows/${lockName}`);
const res = await comfyApiFetch(`/api/userdata/${encoded}`);
if (res.status === 404)
return null;
if (!res.ok) {
throw new ValidationError(`Failed to read lock for "${filename}": ${describeStatus(res.status, res.statusText)}.`);
}
// unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the
// HTTP status is reported either way, so an unreadable body costs detail in the
// text, never a wrong conclusion. Verified there is no branch on this value.
const text = await res.text().catch(() => "");
return parseWorkflowLock(text);
}
async function saveLockToLibrary(filename, lock) {
const lockName = `${filename}.lock.json`;
const encoded = encodeURIComponent(`workflows/${lockName}`);
const res = await comfyApiFetch(`/api/userdata/${encoded}`, {
method: "POST",
body: JSON.stringify(lock, null, 2),
});
if (!res.ok) {
// unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the
// HTTP status is reported either way, so an unreadable body costs detail in the
// text, never a wrong conclusion. Verified there is no branch on this value.
//
// #385 made this branch REACHABLE, so a body that was never printed before now
// can be. A gateway that reflects the request can put our own credential in it,
// so it goes through the same scrubber every other printed body goes through.
const text = await res.text().catch(() => "");
throw new ValidationError(`Failed to save lock file for ${filename}: ${describeStatus(res.status, res.statusText)}${text ? `\n${bodyPrefixOf(text)}` : ""}`);
}
}
/**
* `save_workflow (action:"lock")` — the body of the retired provenance-lock
* tool, verbatim (0.50.0 slice 14). Same library load, same `generateLock`,
* same `<filename>.lock.json` write, same content block.
*
* This action WRITES, which is why `save_workflow`'s `call_tool` admission is
* action-scoped to `save` alone — see src/orchestrator/call-tool-admission.ts.
*/
export async function lockWorkflowAction(filename) {
const workflow = await loadWorkflowFromLibrary(filename);
const lock = await generateLock(workflow);
await saveLockToLibrary(filename, lock);
return {
content: [
{
type: "text",
text: `Locked "${filename}" against ComfyUI ${lock.comfyui_version}.\n` +
`- ${lock.models.length} model(s), ${lock.models.filter((m) => m.sha256).length} hashed, ${lock.models.filter((m) => m.missing).length} missing on disk.\n` +
`- ${lock.node_packs.length} custom pack(s), ${lock.node_packs.filter((p) => p.commit_sha).length} with commit recorded.\n\n` +
"```json\n" +
JSON.stringify(lock, null, 2) +
"\n```",
},
],
};
}
/**
* `save_workflow (action:"verify_lock")` — the body of the retired lock-drift
* tool, verbatim (0.50.0 slice 14). Read-only: loads the lock, recomputes a
* current one, and reports the diff.
*
* Both throw rather than returning `errorToToolResult`; the caller wraps every
* action in the identical catch, so failures render exactly as before.
*/
export async function verifyWorkflowLockAction(filename) {
const [workflow, lock] = await Promise.all([
loadWorkflowFromLibrary(filename),
loadLockFromLibrary(filename),
]);
if (!lock) {
return {
content: [
{
type: "text",
text: `No lock present for "${filename}" — there is nothing to verify yet. ` +
`Run save_workflow (action:"lock") on "${filename}" first to create ` +
`"${filename}.lock.json", then save_workflow (action:"verify_lock") will report drift against it.`,
},
],
};
}
const current = await generateLock(workflow);
const drift = diffLocks(lock, current);
const driftCount = drift.models.length + drift.node_packs.length + (drift.comfyui_version ? 1 : 0);
if (driftCount === 0) {
return {
content: [
{
type: "text",
text: `No drift — "${filename}" still matches its lock from ${lock.generated_at}.`,
},
],
};
}
const lines = [
`**Drift detected for "${filename}"** — ${driftCount} change(s) since the lock was generated at ${lock.generated_at}.`,
"",
];
if (drift.comfyui_version) {
lines.push(`**ComfyUI version**: locked at ${drift.comfyui_version.lock}, currently ${drift.comfyui_version.current}.`, "");
}
if (drift.models.length > 0) {
lines.push("**Models:**");
for (const m of drift.models) {
if (m.status === "missing") {
lines.push(`- 🚫 missing — ${m.type}/${m.name} (locked sha256 \`${m.lock_sha256?.slice(0, 12) ?? "?"}\`)`);
}
else if (m.status === "changed") {
lines.push(`- ✏️ changed — ${m.type}/${m.name} (locked \`${m.lock_sha256?.slice(0, 12)}\` → current \`${m.current_sha256?.slice(0, 12)}\`)`);
}
else {
lines.push(`- ➕ added — ${m.type}/${m.name}`);
}
}
lines.push("");
}
if (drift.node_packs.length > 0) {
lines.push("**Node packs:**");
for (const p of drift.node_packs) {
if (p.status === "missing") {
lines.push(`- 🚫 missing — ${p.id} (locked commit \`${p.lock_commit?.slice(0, 7) ?? "?"}\`)`);
}
else if (p.status === "changed") {
lines.push(`- ✏️ commit changed — ${p.id} (\`${p.lock_commit?.slice(0, 7)}\` → \`${p.current_commit?.slice(0, 7)}\`)`);
}
else {
lines.push(`- ➕ added — ${p.id}`);
}
}
lines.push("");
}
lines.push("```json\n" + JSON.stringify(drift, null, 2) + "\n```");
return {
content: [{ type: "text", text: lines.join("\n") }],
};
}
//# sourceMappingURL=workflow-lock.js.map