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),
167 lines • 8.02 kB
JavaScript
/**
* #1425 — the wire contract for a node id, including the SUBGRAPH-QUALIFIED shape.
*
* #845 made the tools accept back an id they had printed: the graph readers return
* ids as strings (`"42"`), so `"42"` and `42` both had to mean node 42. That change
* deliberately stopped short of `"5:12"`:
*
* > a subgraph-qualified id is a real shape in newer ComfyUI, and silently
* > truncating it to `5` would target the WRONG node rather than fail. If those
* > need supporting, that is a separate, deliberate change to the wire contract.
*
* This is that change. Unpacking a subgraph leaves genuine ROOT-level nodes carrying
* ids like `120:104` and `120:113:78`; the readers list them and render them, and
* every write tool refused them — 18 of 21 nodes uneditable in the reporter's
* workflow. So the refusal was not protecting anything by then, it was just the
* conservative half of a trade whose other half never arrived.
*
* The two halves have to move TOGETHER. Widening the pattern alone would send
* `"263:78"` through `Number.parseInt(…, 10)` and yield `263` — turning a loud
* refusal into a silent edit of a DIFFERENT node, which is the exact outcome #845
* refused to risk. So a qualified id is never converted: it stays the string the
* reader printed, all the way to the panel.
*/
/**
* A plain integer id, the form the wire has always carried.
*
* Exported because one argument needs this shape ON ITS OWN: panel_run's
* `to_node_id` (#1497). Everything else takes NODE_ID_PATTERN, which admits the
* qualified form too — but a run-to-node target is resolved by NUMBER all the way
* down (the panel's findNodeInScopes does `Number(id)`, and its reply reports
* `ran_to_node: Number(to_node_id)`), so admitting `"120:104"` there would trade a
* clear schema refusal for a panel-side "node not found" about a node that exists.
* The shape lives here so the two patterns cannot drift apart.
*/
export const PLAIN_NODE_ID_PATTERN = /^-?\d+$/;
/**
* A subgraph-qualified id: integer segments joined by colons (`120:104`,
* `120:113:78`). No depth limit — nesting is arbitrary, and a limit would fail the
* deep case in exactly the way this fixes the shallow one. Only the FIRST segment
* may be negative: a leading `-` marks the boundary-rail ids the readers emit, and
* a `-` inside a path would be a shape nothing produces.
*/
const QUALIFIED = /^-?\d+(?::\d+)+$/;
/**
* #2855 — a named id the graph readers print for API-style / string-id graphs
* (`sampler`, `mac_studio_vlm`). Must start with a letter or underscore so it
* cannot collide with the integer/`42px` cases: `42px` is still refused, never
* truncated via parseInt.
*/
const NAMED = /^[A-Za-z_][A-Za-z0-9_-]*$/;
/**
* Integer, subgraph-qualified, and named spellings in ONE pattern, so it can be
* handed to `z.string().regex()`.
*
* That matters beyond tidiness: `.regex()` puts a `pattern` on the advertised MCP
* input schema, while `.refine()` renders as a bare `{"type":"string"}` (measured
* with zod 4's toJSONSchema). Validating through a refine would therefore have told
* every client LESS about a node id than the old integer-only rule did.
*/
export const NODE_ID_PATTERN = /^(?:-?\d+(?::\d+)*|[A-Za-z_][A-Za-z0-9_-]*)$/;
/** Does this string name a node at all — either spelling? */
export function isNodeIdString(v) {
return NODE_ID_PATTERN.test(v);
}
/** True for the qualified form specifically (`"5:12"`), false for `"5"`. */
export function isQualifiedNodeId(v) {
return QUALIFIED.test(v);
}
/**
* Normalize an accepted id to what goes on the wire.
*
* A plain id becomes the NUMBER the panel has always received, so nothing about
* the existing surface changes. A qualified id and a named id are returned
* VERBATIM — parsing them is precisely the bug. Callers must therefore treat a
* node id as `number | string` rather than assuming the number.
*/
export function normalizeNodeId(v) {
if (typeof v === "number")
return v;
if (QUALIFIED.test(v))
return v;
if (NAMED.test(v))
return v;
return Number.parseInt(v, 10);
}
/** The message a rejected id gets. Names the qualified and named shapes, because a
* caller holding `263:78` or `sampler` needs to know whether it is unsupported or malformed. */
export const NODE_ID_MESSAGE = "a node id must be an integer (e.g. 42), a subgraph-qualified id (e.g. 120:104), or a named id the graph readers printed (e.g. sampler)";
/**
* #1889 — a bare `z.union` renders as the word `Invalid input` and nothing else.
*
* Zod 4 reports a failed union as ONE `invalid_union` issue whose own `message`
* is the constant `"Invalid input"`; the per-member messages (NODE_ID_MESSAGE
* among them) live in a nested `errors` array. Every renderer that reaches an
* agent flattens `issue.message` and drops that nest — the MCP SDK's
* `getParseErrorMessage` (1.30) joins `` `${i.message} at ${path}` ``, zod's own
* `prettifyError` prints `✖ ${i.message}`, and 1.29 JSON-stringifies the issues.
* So a node-id rejection read:
*
* Invalid input at node_id
* Invalid input: expected string, received undefined at title
*
* NODE_ID_MESSAGE was never dead prose — a BAD STRING (`"42px"`) fails only the
* string member, zod collapses that to a plain `invalid_format` issue, and the
* message prints. It is unreachable only for inputs that fail EVERY member
* (undefined, null, 4.5, an object), which is the common case and the reported
* one. This is what makes the union say the same thing there.
*
* `received` is carried too, because the complaint was the asymmetry with the
* sibling `title`, and half of what `title` said was what it actually got.
*/
export function unionErrorFor(expected) {
return (iss) => `${expected}; received ${describeReceived(iss.input)}`;
}
/** How long a rendered value may get before it is cut. A node id is a handful of
* characters; anything near this is a caller error worth showing, not quoting. */
const RECEIVED_MAX = 60;
/**
* Render an arbitrary rejected input for a message, TOTALLY.
*
* This runs inside zod's error path, so it must never throw: a formatter that
* throws converts a clean validation refusal into a 500. `JSON.stringify` alone
* is not safe here — it throws on a BigInt and on a circular object, returns the
* bare `undefined` value for a symbol or a function, and renders NaN/Infinity as
* `null`, which would tell a caller who passed NaN that they passed null. Each
* of those is handled before the stringify, and the stringify itself is caught.
*/
export function describeReceived(input) {
if (input === null)
return "null";
switch (typeof input) {
case "undefined":
return "undefined";
case "bigint":
return `${input}n`;
case "symbol":
return input.toString();
case "function":
return "a function";
case "number":
case "boolean":
// `String` and not `JSON.stringify`: the latter renders NaN and ±Infinity
// as `null`, which would tell a caller who passed NaN that they passed null.
return String(input);
default:
break;
}
let rendered;
try {
rendered = JSON.stringify(input) ?? String(input);
}
catch {
// The fallback needs its own guard: `Array.isArray` THROWS on a revoked
// proxy ("Cannot perform 'IsArray' on a proxy that has been revoked"), so
// the naive recovery path was itself a way for this function to throw.
// Measured, not assumed — it is the one hostile input of ten that got past
// the outer catch.
try {
return Array.isArray(input) ? "an array" : "an object";
}
catch {
return "an object";
}
}
return rendered.length > RECEIVED_MAX ? `${rendered.slice(0, RECEIVED_MAX)}…` : rendered;
}
//# sourceMappingURL=node-id.js.map