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),
172 lines • 6.15 kB
JavaScript
import { readdir, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, basename, extname } from "node:path";
import { homedir } from "node:os";
import { z } from "zod";
import { ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
const PROMPT_TOKEN = "PARAM_PROMPT";
const TYPED_RE = /^PARAM_(INT|FLOAT|STRING|BOOL)_([A-Z][A-Z0-9_]*)$/;
/**
* Scan a workflow JSON for PARAM_* placeholders and return a list of
* (name, type, nodeId, inputName) entries — one per *site*, so duplicates
* across nodes are preserved (applyParams fills every site).
*/
export function parsePlaceholders(workflow) {
const placeholders = [];
for (const [nodeId, node] of Object.entries(workflow)) {
if (!node.inputs)
continue;
for (const [inputName, value] of Object.entries(node.inputs)) {
if (typeof value !== "string")
continue;
if (value === PROMPT_TOKEN) {
placeholders.push({ name: "prompt", type: "string", nodeId, inputName });
continue;
}
const match = TYPED_RE.exec(value);
if (!match)
continue;
const [, kind, rawName] = match;
const type = kind === "INT" ? "int" : kind === "FLOAT" ? "float" : kind === "BOOL" ? "bool" : "string";
placeholders.push({
name: rawName.toLowerCase(),
type,
nodeId,
inputName,
});
}
}
return placeholders;
}
function zodForType(type) {
switch (type) {
case "int":
return z.coerce.number().int();
case "float":
return z.coerce.number();
case "bool":
return z.coerce.boolean();
case "string":
return z.string();
}
}
/**
* Build a Zod shape object keyed by unique placeholder name. All placeholders
* are required for v1 — to expose a default, edit the workflow JSON to use a
* real value instead of a PARAM_* token.
*/
export function buildToolSchema(placeholders) {
const shape = {};
for (const p of placeholders) {
if (!(p.name in shape)) {
shape[p.name] = zodForType(p.type).describe(`Value for "${p.name}" (${p.type}); fills a PARAM_* placeholder in the workflow.`);
}
}
return shape;
}
/**
* Replace every PARAM_* placeholder in `workflow` with values from `params`.
* Throws ValidationError if a required name is missing. Returns a new workflow
* (does not mutate input).
*/
export function applyParams(workflow, placeholders, params) {
const next = JSON.parse(JSON.stringify(workflow));
const needed = new Set(placeholders.map((p) => p.name));
for (const name of needed) {
if (!(name in params) || params[name] === undefined) {
throw new ValidationError(`Missing required param: ${name}`);
}
}
for (const p of placeholders) {
const node = next[p.nodeId];
if (!node)
continue;
const raw = params[p.name];
const coerced = coerce(raw, p.type, p.name);
node.inputs[p.inputName] = coerced;
}
return next;
}
function coerce(value, type, name) {
switch (type) {
case "int": {
const n = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(n) || !Number.isInteger(n)) {
throw new ValidationError(`Param "${name}" must be an integer, got ${JSON.stringify(value)}`);
}
return n;
}
case "float": {
const n = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(n)) {
throw new ValidationError(`Param "${name}" must be a number, got ${JSON.stringify(value)}`);
}
return n;
}
case "bool":
if (typeof value === "boolean")
return value;
if (value === "true")
return true;
if (value === "false")
return false;
throw new ValidationError(`Param "${name}" must be a boolean`);
case "string":
return String(value);
}
}
export function getWorkflowsDir() {
return process.env.COMFYUI_WORKFLOWS_DIR ?? join(homedir(), ".comfyui-mcp", "workflows");
}
function slugify(filename) {
const base = basename(filename, extname(filename));
return base.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase();
}
/**
* Discover all *.json files in the configured workflows directory. Invalid
* JSON files are logged and skipped, never thrown — startup must remain
* resilient to bad user-managed files.
*/
export async function discoverWorkflows(dir = getWorkflowsDir()) {
if (!existsSync(dir))
return [];
let entries;
try {
entries = await readdir(dir);
}
catch (err) {
logger.warn("Failed to read workflows directory", {
dir,
error: err instanceof Error ? err.message : err,
});
return [];
}
const discovered = [];
const seenNames = new Set();
for (const file of entries) {
if (!file.toLowerCase().endsWith(".json"))
continue;
const filePath = join(dir, file);
try {
const raw = await readFile(filePath, "utf-8");
const parsed = JSON.parse(raw);
const placeholders = parsePlaceholders(parsed);
let toolName = slugify(file);
if (seenNames.has(toolName)) {
logger.warn("Duplicate workflow tool name, skipping", { toolName, filePath });
continue;
}
seenNames.add(toolName);
discovered.push({ toolName, filePath, workflow: parsed, placeholders });
}
catch (err) {
logger.warn("Failed to load workflow JSON, skipping", {
filePath,
error: err instanceof Error ? err.message : err,
});
}
}
return discovered;
}
//# sourceMappingURL=workflow-autoload.js.map