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),

172 lines • 6.15 kB
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