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),
321 lines • 16.2 kB
JavaScript
/**
* #1968 — the argument name an agent reaches for FIRST is accepted, and the
* canonical one stays canonical.
*
* The premise, from the report: if a capable model calls a tool with the obvious
* argument and gets a validation error, that is a naming/schema defect, not a
* caller error. The first guess is drawn from the tool's own name plus the
* conventions of its siblings, so a rejected first guess costs a full round trip
* AND teaches the caller to be tentative with a surface it should use fluently.
*
* This module is the ONE place an alias is resolved, so the three group tools and
* the todo/media tools cannot drift into disagreeing about which spelling wins.
*
* ALIAS, NEVER REPLACE. Every canonical key keeps working untouched and stays the
* name the tool DESCRIPTION teaches — one vocabulary to learn. The alias exists
* so a first call lands, not so there are two names to choose between. That is
* the same trade #1018 made for the todo STATUS synonyms (accept `in_progress`,
* document only `active`) and #845 made for string node ids.
*
* AN AMBIGUOUS PAIR IS REFUSED, NOT RESOLVED. When a caller supplies both the
* canonical key and its alias with DIFFERENT values there is no way to know which
* they meant, and picking one would apply an edit they did not ask for. Equal
* values are accepted (a caller belt-and-bracing both spellings is unambiguous);
* unequal ones get a refusal naming both, which is a read rather than a wrong
* mutation to undo.
*/
import { describeReceived } from "./node-id.js";
/** A group id as the graph readers print it. Integers only: the panel resolves a
* group with a strict `===` against `group.id` (resolveGroup), so a string that
* reached the wire would miss a group that exists and report "No group with id
* …" about one the caller is looking at. Coerced here instead. */
export const GROUP_ID_PATTERN = /^-?\d+$/;
export const GROUP_ID_MESSAGE = "a group id must be an integer (e.g. 2) — panel_query_graph's groups[] and panel_create_group both return it";
export const GROUP_REF_MESSAGE = "a group reference must be an integer id (e.g. 2) or a non-empty group title (e.g. 'Notes')";
/**
* Normalize an accepted group id to the NUMBER the wire has always carried.
*
* Mirrors normalizeNodeId's contract minus the subgraph-qualified shape: a group
* id has no nested form, so unlike a node id there is nothing here that must pass
* through as a string.
*/
export function normalizeGroupId(v) {
return typeof v === "number" ? v : Number.parseInt(v, 10);
}
/**
* Resolve a canonical key and its alias to one value.
*
* Equality is by JSON rather than `===` so an array/object argument (`items` vs
* `todos`) compares by CONTENT — two structurally identical checklists are not
* ambiguous just because they are different references. JSON.stringify can throw
* on a circular or BigInt value, so a comparison that cannot be made is treated
* as "not equal" (refuse) rather than as agreement: refusing an unreadable pair
* costs a round trip, silently picking one applies the wrong list.
*/
export function pickAlias(subject, canonical, alias) {
const hasCanonical = canonical.value !== undefined;
const hasAlias = alias.value !== undefined;
if (hasCanonical && hasAlias) {
if (!sameValue(canonical.value, alias.value)) {
return {
ok: false,
error: `${subject} got both \`${canonical.name}\` and \`${alias.name}\`, with different values ` +
`(${canonical.name}=${describeReceived(canonical.value)}, ${alias.name}=${describeReceived(alias.value)}). ` +
`They are the SAME argument — \`${alias.name}\` is an accepted alias for \`${canonical.name}\` — so this ` +
`call names one target twice and does not say which. Nothing was sent. Re-send with ONE of them.`,
};
}
return { ok: true, value: canonical.value };
}
if (hasCanonical)
return { ok: true, value: canonical.value };
if (hasAlias)
return { ok: true, value: alias.value };
return {
ok: false,
error: `${subject} requires \`${canonical.name}\` (or its alias \`${alias.name}\`) — neither was supplied.`,
};
}
/** Structural equality for the alias check. Never throws: an unstringifiable pair
* is reported as unequal so the caller gets the refusal, not a coin flip. */
function sameValue(a, b) {
if (a === b)
return true;
try {
return JSON.stringify(a) === JSON.stringify(b);
}
catch {
return false;
}
}
/**
* #1968 — `panel_show_media` items: accept the ComfyUI output ref at the ITEM
* level and wrap it into `source`.
*
* The tool's OWN prose says "pass the output ref `{filename, subfolder, type}`",
* and that is exactly what the reporter passed — one level too shallow, because
* the schema wants it nested under `source`. A doc that describes the payload and
* a schema that wants it wrapped is a defect in the pair, not in the caller.
*
* Adds the wrapper for the flat form. An item that already carries `source`
* keeps that canonical source; an equivalent flat alias is checked and folded
* only when it names the same ref, so the canonical shape cannot be replaced by
* a conflicting outer value.
*
* An item carrying NEITHER shape is refused here rather than passed on. The
* handler's first act on an item is `"path" in src`, which throws a TypeError on
* an absent source — turning a caller's misnamed key into an unhandled crash
* instead of a sentence they can act on.
*/
export function normalizeShowMediaItem(item, index) {
const { source, filename, subfolder, type, path, stage, label, caption, ...rest } = item;
const at = `panel_show_media items[${index}]`;
const out = { ...rest };
if (source !== undefined) {
const nested = source;
const nestedIsPath = "path" in nested;
const flatFilename = typeof filename === "string" && filename.length > 0 ? filename : undefined;
const flatPath = typeof path === "string" && path.length > 0 ? path : undefined;
// The Panel's producer keeps the whole flat alias row in its payload and
// fills unused members with empty strings. Those placeholders are not a
// second source and must not reach the schema's `min(1)` gate. A populated
// alias beside `source` is also safe when it is the exact same ref: some
// producers send both representations while moving between the two
// documented shapes. Different members still name two media sources and
// remain a refusal.
if (flatFilename !== undefined || flatPath !== undefined) {
const equivalent = nestedIsPath
? flatPath !== undefined && nested.path === flatPath && flatFilename === undefined
: flatFilename !== undefined && nested.filename === flatFilename && flatPath === undefined;
if (!equivalent) {
const flatName = flatFilename !== undefined ? "`filename`" : "`path`";
return {
ok: false,
error: `${at} carries \`source\` AND a top-level ${flatName}. ` +
`The flat form is an accepted alias for \`source\`, so this item names its media twice. ` +
`Nothing was displayed. Send the ref in ONE place.`,
};
}
}
// The ref's MODIFIERS — subfolder/type/stage — can arrive flat while the ref
// itself is nested. Before this alias existed zod stripped them from the item
// silently; now the schema ADVERTISES them, so dropping one would be a
// parameter that is accepted and does nothing. `stage` is the one that
// matters: it is the opt-in for a real disk write, so a caller who sets it
// and is ignored gets a refusal for an oversized file they thought they had
// handled. Fold each into `source` where it is absent, and refuse rather
// than overwrite where the two disagree.
const merged = { ...nested };
for (const [key, value] of [
["subfolder", subfolder],
["type", type],
["stage", stage],
]) {
if (value === undefined)
continue;
if (merged[key] !== undefined && !sameValue(merged[key], value)) {
return {
ok: false,
error: `${at} sets \`${key}\` both inside \`source\` (${describeReceived(merged[key])}) and at item ` +
`level (${describeReceived(value)}). The flat key is an alias for the nested one, so this ` +
`says two things about one ref. Nothing was displayed. Set it in ONE place.`,
};
}
merged[key] = value;
}
out.source = merged;
}
else {
// Empty flat alias members are Panel producer placeholders, not media refs.
// Treat them as absent here, then keep the existing one-source union and
// the explicit no-media refusal below.
const flatFilename = typeof filename === "string" && filename.length > 0 ? filename : undefined;
const flatPath = typeof path === "string" && path.length > 0 ? path : undefined;
if (flatPath !== undefined && flatFilename !== undefined) {
return {
ok: false,
error: `${at} carries both flat \`filename\` and \`path\`. ` +
`Those are two different members of the media-source union, so the item does not say which media to display. ` +
`Nothing was displayed. Send exactly one of them.`,
};
}
if (flatPath !== undefined) {
// The disk-path member of the same union, flattened the same way.
out.source = stage === undefined ? { path: flatPath } : { path: flatPath, stage };
}
else if (flatFilename !== undefined) {
out.source = {
filename: flatFilename,
...(subfolder === undefined ? {} : { subfolder }),
...(type === undefined ? {} : { type }),
...(stage === undefined ? {} : { stage }),
};
}
else {
return {
ok: false,
error: `${at} has no media to show: it needs \`source\` — or, equivalently, a top-level \`filename\` ` +
`(a ComfyUI output ref: {filename, subfolder?, type?}) or \`path\` (an absolute disk path).`,
};
}
}
// `caption` is and remains canonical (it has been the key since v0.19.0 — the
// report's claim that the tool "requires label" is the one row of its table
// that does not reproduce). `label` is accepted because a caller who guessed it
// once will guess it again, and a dropped caption is a silent loss: zod strips
// an unknown key from a NESTED object rather than refusing it, so the media
// would have rendered captionless with no error to read.
const text = pickAliasOptional(at, { name: "caption", value: caption }, { name: "label", value: label });
if (!text.ok)
return text;
if (text.value !== undefined)
out.caption = text.value;
return { ok: true, value: out };
}
/**
* #1968 — `panel_set_todo` items: accept `content` for `text`.
*
* "todo" is in the tool's name and `content` is the key the analogous built-in
* TODO surface uses, so it is the first guess. It failed as a SECOND error after
* `todos`-for-`items` was fixed, because zod strips an unrecognized key from a
* nested object and then reports the canonical one missing — the caller pays two
* round trips for one misnamed pair.
*
* `text` is therefore OPTIONAL in the schema and required HERE, so that a missing
* step description is still refused — the alias widens which spelling lands, and
* must not quietly make the field itself skippable.
*/
export function normalizeTodoItemKeys(items) {
const out = [];
for (const [index, item] of items.entries()) {
if (!item || typeof item !== "object") {
return { ok: false, error: `panel_set_todo items[${index}] is not an object.` };
}
const { content, ...rest } = item;
const picked = pickAlias(`panel_set_todo items[${index}]`, { name: "text", value: rest.text }, { name: "content", value: content });
if (!picked.ok)
return picked;
out.push({ ...rest, text: picked.value });
}
return { ok: true, value: out };
}
/** pickAlias for a pair that is genuinely OPTIONAL: absent on both sides is a
* valid answer (undefined), while a conflicting pair is still refused. */
function pickAliasOptional(subject, canonical, alias) {
if (canonical.value === undefined && alias.value === undefined)
return { ok: true, value: undefined };
return pickAlias(subject, canonical, alias);
}
/** The projections `panel_find_nodes` offers, matching `panel_query_graph`'s
* `fields` exactly — same three names, same meanings, so one is learned once. */
export const FIND_NODES_FIELDS = ["ids", "compact", "detail"];
/**
* The default is `compact`, NOT `detail`, and that is a deliberate behaviour
* change (#1968).
*
* `find_nodes` had no verbosity control at all while its sibling `query_graph`
* defaulted to compact, so a search — the cheap orienting call — was the most
* expensive read on the surface. The reporter measured four matches on a
* TEN-node graph returning a very large payload; on a realistic 50-node graph
* with a common term that is a meaningful fraction of an agent's context spent
* on a lookup. Full detail is still one argument away and the reply says which
* projection it applied, so nothing is lost silently.
*/
export const FIND_NODES_DEFAULT_FIELDS = "compact";
/**
* The keys a `compact` match row keeps.
*
* Everything that says WHICH node this is and WHY it matched; nothing that
* re-describes the node's interior. The dropped keys are the bulk — `widgets`,
* `inputs` (each with its `connected_from`), `outputs`, and the node
* `description` — and every one of them is reachable per-node with
* panel_query_graph({ids:[id], fields:'detail'}) or by re-running with
* fields:'detail'.
*
* `matched_on` is KEPT on purpose even though it is a list: it is the answer to
* the question the caller asked (why is this node in my results?), and dropping
* it would make a compact result a worse search reply rather than a cheaper one.
*/
const COMPACT_MATCH_KEYS = [
"id",
"type",
"title",
"mode",
"is_output",
"is_subgraph",
"matched_on",
];
/**
* Project one match row. Unknown keys are DROPPED for compact, not carried:
* carrying them would make the bound depend on the panel build, and a projection
* that shrinks by an unpredictable amount is not a bound at all.
*/
function compactMatch(match) {
const out = {};
for (const key of COMPACT_MATCH_KEYS) {
if (match[key] !== undefined)
out[key] = match[key];
}
return out;
}
/**
* Apply a `fields` projection to a `graph_find_nodes` payload, IN PLACE on a copy.
*
* Returns null when there was nothing to project — no `matches` array, or
* `fields:'detail'`, which is today's behaviour byte for byte. A null return is
* how the caller knows NOT to stamp `fields` onto the reply: announcing a
* projection that did not run is the "schema says accept, not do" defect, and it
* would be indistinguishable from a real one to the agent reading it.
*/
export function projectFindNodesPayload(payload, fields) {
if (fields === "detail")
return null;
const matches = payload.matches;
if (!Array.isArray(matches))
return null;
const projected = fields === "ids"
? matches.map((m) => (m && typeof m === "object" ? m.id : m))
: matches.map((m) => m && typeof m === "object" ? compactMatch(m) : m);
return { ...payload, matches: projected, fields };
}
//# sourceMappingURL=panel-arg-aliases.js.map