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

207 lines • 8.43 kB
// a2ui-spec.ts — server-side validation of A2UI card specs (panel_ui_render / // panel_ui_update). MUST stay in lockstep with the panel-side validator in // comfyui-agent-panel/web/js/cmcp-a2ui.js — same caps, same vocabulary. The // tool layer rejects here so the agent gets a retryable error; the panel // re-validates before rendering (fence path has only the panel check). // // HARDENING NOTE: the panel-side validator gained four security fixes after // this shape was first drafted, and this file mirrors all four so the two // walls can't drift apart: // 1. Children-array length cap: each Row/Column/Card `children` array is // capped at maxComponents (64) entries, via `.max()` on the zod schema. // 2. Render-tree INSTANCE counting in the DFS: repeated child references // multiply the actual render tree, so the walk below counts every DFS // VISIT (not just declared components) and rejects once total instances // exceed maxComponents. // 3. Image INSTANCE cap in the DFS: same idea for Image — the walk counts // every Image visited (not just declared), rejecting past maxImages. // 4. Chart x-array length cap: `x` is capped at maxChartPoints, same as a // series' `values` array. import { z } from "zod"; export const A2UI_CAPS = { maxComponents: 64, maxDepth: 8, maxGraphNodes: 30, maxGraphEdges: 60, maxChartSeries: 8, maxChartPoints: 256, maxSelectOptions: 24, maxImages: 4, maxTextLen: 2000, maxLabelLen: 200, }; // LOCKSTEP: the panel's `capped()` check is MAX-ONLY — it accepts empty strings // for every field except component ids (which additionally require `!c.id` to be // false, i.e. non-empty). So the shared string primitives here must NOT enforce // non-empty, or a spec the panel wall accepts (caption:"", placeholder:"", // label:"", …) would bounce off the tool wall. Only `id` (component ids, graph // node ids, edge endpoints, and root) requires a non-empty string. const label = z.string().max(A2UI_CAPS.maxLabelLen); const long = z.string().max(A2UI_CAPS.maxTextLen); const id = z.string().min(1).max(A2UI_CAPS.maxLabelLen); const imageSrc = z .string() .refine((s) => /^\/(api\/)?view\?/.test(s) || /^blob:/.test(s) || /^data:image\//.test(s), { message: "image src not allowed (ComfyUI /view, blob:, data:image/ only)", }); // Mirror-hardening #1: cap each container's children array at maxComponents so // a single declared container can't smuggle in a huge (e.g. 50k-entry) fan-out // via repeated ids — the DFS instance count (hardening #2) catches the deeper // nesting variant, this catches the flat one at the schema layer. const children = z.array(label).max(A2UI_CAPS.maxComponents); const component = z.discriminatedUnion("type", [ z.object({ id, type: z.literal("Text"), text: long }), z.object({ id, type: z.literal("Heading"), text: label, level: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(), }), z.object({ id, type: z.literal("Button"), label, reply: long.optional(), submit: z.boolean().optional(), style: z.enum(["primary", "secondary"]).optional(), }), z.object({ id, type: z.literal("Row"), children }), z.object({ id, type: z.literal("Column"), children }), z.object({ id, type: z.literal("Card"), children }), z.object({ id, type: z.literal("Divider") }), z.object({ id, type: z.literal("Image"), src: imageSrc, caption: label.optional() }), z.object({ id, type: z.literal("TextField"), label, name: label, value: long.optional(), placeholder: label.optional(), }), z.object({ id, type: z.literal("Select"), label, name: label, value: label.optional(), options: z .array(z.object({ label, value: label.optional() })) .min(1) .max(A2UI_CAPS.maxSelectOptions), }), z.object({ id, type: z.literal("Checkbox"), label, name: label, checked: z.boolean().optional() }), z.object({ id, type: z.literal("comfy:graph"), nodes: z .array(z.object({ id, label, color: z.string().regex(/^#[0-9a-fA-F]{3,8}$/).optional() })) .min(1) .max(A2UI_CAPS.maxGraphNodes), edges: z.array(z.object({ from: id, to: id, label: label.optional() })).max(A2UI_CAPS.maxGraphEdges).optional(), direction: z.enum(["lr", "tb"]).optional(), }), z.object({ id, type: z.literal("comfy:chart"), kind: z.enum(["bar", "line"]), series: z .array(z.object({ label, values: z.array(z.number().finite()).min(1).max(A2UI_CAPS.maxChartPoints) })) .min(1) .max(A2UI_CAPS.maxChartSeries), // Mirror-hardening #4: cap x at maxChartPoints, same as series.values. x: z.array(label).max(A2UI_CAPS.maxChartPoints).optional(), }), ]); export const a2uiSpecSchema = z.object({ surface: z.enum(["inline", "wide"]).default("inline"), title: label.optional(), root: id, components: z.array(component).min(1).max(A2UI_CAPS.maxComponents), }); const CONTAINERS = new Set(["Row", "Column", "Card"]); export function validateA2UISpecServer(raw) { const parsed = a2uiSpecSchema.safeParse(raw); if (!parsed.success) { return { ok: false, errors: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).slice(0, 8), }; } const spec = parsed.data; const errors = []; const byId = new Map(); for (const c of spec.components) { if (byId.has(c.id)) errors.push(`duplicate id "${c.id}"`); byId.set(c.id, c); } if (!byId.has(spec.root)) errors.push(`root: unknown component id "${spec.root}"`); let images = 0; for (const c of byId.values()) { if (c.type === "Image") images++; if (CONTAINERS.has(c.type)) { for (const k of c.children) { if (!byId.has(k)) errors.push(`"${c.id}": child references unknown component id "${k}"`); } } if (c.type === "comfy:graph") { const ids = new Set(c.nodes.map((n) => n.id)); for (const e of c.edges ?? []) { if (!ids.has(e.from) || !ids.has(e.to)) errors.push(`"${c.id}": edge references unknown graph node`); } } } if (images > A2UI_CAPS.maxImages) errors.push(`too many images (${images} > ${A2UI_CAPS.maxImages})`); if (errors.length) return { ok: false, errors }; // Cycle + depth check via DFS from root — and cap total INSTANCES: repeated // child references multiply the render tree, so we count every DFS VISIT, // not just declared components (mirror-hardenings #2 and #3 — 2 declared // components must not be able to render 50k node instances, and 1 declared // Image referenced 5 times must not render 5 images). const visiting = new Set(); let instances = 0; let imageInstances = 0; const walk = (id, depth) => { if (errors.length) return; if (++instances > A2UI_CAPS.maxComponents) { errors.push(`render tree exceeds ${A2UI_CAPS.maxComponents} component instances (repeated child references count)`); return; } if (depth > A2UI_CAPS.maxDepth) { errors.push(`nesting depth exceeds ${A2UI_CAPS.maxDepth}`); return; } if (visiting.has(id)) { errors.push(`reference cycle through "${id}"`); return; } const c = byId.get(id); if (c.type === "Image" && ++imageInstances > A2UI_CAPS.maxImages) { errors.push(`render tree exceeds ${A2UI_CAPS.maxImages} image instances`); return; } if (!CONTAINERS.has(c.type)) return; visiting.add(id); for (const k of c.children) { walk(k, depth + 1); if (errors.length) { visiting.delete(id); return; } } visiting.delete(id); }; walk(spec.root, 1); if (errors.length) return { ok: false, errors }; return { ok: true, spec }; } //# sourceMappingURL=a2ui-spec.js.map