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),
247 lines • 9.88 kB
JavaScript
import { getObjectInfo } from "../comfyui/client.js";
import { enqueueWorkflow } from "./workflow-executor.js";
import { isApiNode, generateWithApiNode, } from "./api-nodes.js";
import { ValidationError } from "../utils/errors.js";
/**
* generate_image (action:"3d") — produce a 3D model (glb/obj/fbx/ply) from a
* text prompt or an
* input image.
*
* PATH CHOSEN (investigated 2026-07-24): current ComfyUI ships hosted partner
* 3D nodes in core (`comfy_api_nodes`) — Tripo, Meshy, Rodin and Hunyuan3D
* (Tencent), all under categories like "partner/3d/Tripo" with FILE_3D_*
* outputs and OUTPUT_NODE=true. We therefore route through the SAME
* generateWithApiNode machinery used by the list_api_nodes (action:"generate") tool: no
* local 3D pack install is required, only a comfy.org API key/login on the
* server (or COMFY_API_KEY here). Detection is dynamic against /object_info,
* so the tool degrades honestly when the server has no 3D-capable API nodes
* (old build, --disable-api-nodes) with an actionable error naming local-pack
* alternatives.
*/
const defaultDeps = {
getObjectInfo,
enqueue: enqueueWorkflow,
};
/** Node id used for the LoadImage feeder in image mode ("1"/"2" are reserved). */
const LOAD_IMAGE_NODE_ID = "10";
/** Widget-ish input types that don't need an upstream link. */
const WIDGET_TYPES = new Set(["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"]);
function inputType(spec) {
if (Array.isArray(spec))
return spec[0];
return String(spec);
}
function isWidgetType(type) {
if (Array.isArray(type))
return true; // inline combo options
const t = type.toUpperCase();
return WIDGET_TYPES.has(t) || t.includes("COMBO") || t.includes("AUTOGROW");
}
/** Does this node output a 3D model file? */
function has3dOutput(def) {
return (Array.isArray(def.output) ? def.output : []).some((o) => String(o).toUpperCase().startsWith("FILE_3D"));
}
/**
* A hosted partner/API node that produces a 3D model. Requires an actual
* FILE_3D* output — a 3D-ish category alone is NOT enough, since 3D categories
* also contain post-processing/utility nodes (UV unwrap, retopology, task-id
* plumbing) that would be a false "backend" for generation.
*/
export function is3dApiNode(def) {
return isApiNode(def) && has3dOutput(def);
}
/** Names of required link-type inputs (non-widget — need an upstream node). */
function requiredLinkInputs(def) {
const out = [];
for (const [name, spec] of Object.entries(def.input?.required ?? {})) {
const type = inputType(spec);
if (!isWidgetType(type))
out.push({ name, type: String(type).toUpperCase() });
}
return out;
}
function findStringPromptInput(def) {
for (const section of [def.input?.required, def.input?.optional]) {
for (const [name, spec] of Object.entries(section ?? {})) {
const type = inputType(spec);
if (typeof type === "string" && type.toUpperCase() === "STRING" && /prompt/i.test(name)) {
return name;
}
}
}
return undefined;
}
/** Can this 3D node run from just a text prompt (no required link inputs)? */
export function isTextCapable(def) {
if (requiredLinkInputs(def).length > 0)
return false;
// Must actually accept a prompt.
const req = def.input?.required ?? {};
return Object.entries(req).some(([name, spec]) => {
const type = inputType(spec);
return typeof type === "string" && type.toUpperCase() === "STRING" && /prompt/i.test(name);
});
}
/** Can this 3D node run from a single input image (exactly one required IMAGE link)? */
export function isImageCapable(def) {
const links = requiredLinkInputs(def);
const images = links.filter((l) => l.type === "IMAGE");
if (images.length !== 1 || links.length !== images.length)
return { ok: false };
return { ok: true, imageInput: images[0].name };
}
/** Preferred class_types per mode, best-supported first. */
const PREFERRED = {
text: [
"TripoTextToModelNode",
"MeshyTextToModelNode",
"TencentTextToModelNode",
"Rodin3D_Gen25_Text",
"TripoP1TextToModelNode",
],
image: [
"TripoImageToModelNode",
"MeshyImageToModelNode",
"TencentImageToModelNode",
"Rodin3D_Gen25_Image",
"TripoP1ImageToModelNode",
],
};
function formatsOf(def) {
const out = [];
for (const o of Array.isArray(def.output) ? def.output : []) {
const s = String(o).toUpperCase();
if (s === "FILE_3D")
out.push("3D");
else if (s.startsWith("FILE_3D_"))
out.push(s.slice("FILE_3D_".length));
}
return [...new Set(out)];
}
/** Discover 3D-capable API nodes on the connected server, for a given mode. */
export function find3dApiNodes(objectInfo, mode) {
const out = [];
for (const [classType, def] of Object.entries(objectInfo)) {
if (!def || !is3dApiNode(def))
continue;
if (mode === "text") {
if (!isTextCapable(def))
continue;
out.push({
class_type: classType,
display_name: def.display_name || classType,
category: def.category ?? "",
formats: formatsOf(def),
});
}
else {
const cap = isImageCapable(def);
if (!cap.ok)
continue;
out.push({
class_type: classType,
display_name: def.display_name || classType,
category: def.category ?? "",
formats: formatsOf(def),
image_input: cap.imageInput,
});
}
}
out.sort((a, b) => a.class_type.localeCompare(b.class_type));
return out;
}
function pickCandidate(candidates, mode) {
for (const preferred of PREFERRED[mode]) {
const hit = candidates.find((c) => c.class_type === preferred);
if (hit)
return hit;
}
return candidates[0];
}
function noBackendError(mode) {
return new ValidationError(`No 3D-capable API/partner nodes for ${mode}-to-3D were found on the connected ComfyUI, ` +
`so 3D generation has no backend to run on. Recent ComfyUI builds ship hosted 3D partner nodes ` +
`in core (Tripo, Meshy, Rodin, Hunyuan3D — categories like "partner/3d/..."); they may be ` +
`missing because the ComfyUI build is old or API nodes are disabled (--disable-api-nodes). ` +
`Fixes: (1) update ComfyUI (install_comfyui (action:"update")) and remove --disable-api-nodes, then retry; or ` +
`(2) install a LOCAL 3D pack and build a workflow with it — e.g. install_custom_node with ` +
`"ComfyUI-Hunyuan3DWrapper" (image-to-3D) or "ComfyUI-Flowty-TripoSR" (image-to-3D). ` +
`Nothing was installed automatically.`);
}
/**
* Generate a 3D model from text or an image by enqueueing a minimal workflow
* around a hosted partner 3D node (Tripo/Meshy/Rodin/Hunyuan3D), reusing the
* list_api_nodes (action:"generate") machinery. Returns immediately with the prompt_id.
*/
export async function generate3d(args, deps = defaultDeps) {
if (args.mode === "text" && !args.prompt?.trim()) {
throw new ValidationError('mode "text" requires a non-empty prompt.');
}
if (args.mode === "image" && !args.image?.trim()) {
throw new ValidationError('mode "image" requires an input image filename (upload one first with upload_image (action:"image")).');
}
const objectInfo = await deps.getObjectInfo();
const candidates = find3dApiNodes(objectInfo, args.mode);
if (candidates.length === 0)
throw noBackendError(args.mode);
let chosen;
if (args.node) {
const hit = candidates.find((c) => c.class_type === args.node);
if (!hit) {
throw new ValidationError(`Node "${args.node}" is not a ${args.mode}-capable 3D API node on this server. ` +
`Available: ${candidates.map((c) => c.class_type).join(", ")}.`);
}
chosen = hit;
}
else {
chosen = pickCandidate(candidates, args.mode);
}
const def = objectInfo[chosen.class_type];
const inputs = { ...(args.inputs ?? {}) };
let extra_nodes;
const preNotes = [];
if (args.mode === "text") {
const promptName = findStringPromptInput(def) ?? "prompt";
if (!(promptName in inputs))
inputs[promptName] = args.prompt;
}
else {
const imageInput = chosen.image_input ?? "image";
inputs[imageInput] = [LOAD_IMAGE_NODE_ID, 0];
extra_nodes = {
[LOAD_IMAGE_NODE_ID]: {
class_type: "LoadImage",
inputs: { image: args.image },
_meta: { title: "Load Input Image" },
},
};
if (args.prompt?.trim()) {
const promptName = findStringPromptInput(def);
if (promptName) {
if (!(promptName in inputs))
inputs[promptName] = args.prompt;
}
else {
preNotes.push(`"${chosen.class_type}" takes no text prompt input — the prompt was ignored (image-only node).`);
}
}
}
const result = await generateWithApiNode({
class_type: chosen.class_type,
inputs,
disable_random_seed: args.disable_random_seed,
extra_nodes,
}, deps);
return {
...result,
notes: [...preNotes, ...result.notes],
class_type: chosen.class_type,
display_name: chosen.display_name,
category: chosen.category,
formats: chosen.formats,
alternatives: candidates
.map((c) => c.class_type)
.filter((c) => c !== chosen.class_type),
};
}
//# sourceMappingURL=generate-3d.js.map