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

325 lines • 16.1 kB
// Comfy Cloud client — mirrors the surface of ./client.ts (`getSystemStats`, // `getQueue`, `enqueuePrompt`, `getHistory`, `interrupt`, `fetchImage`, // `uploadImageHttp`, etc.) but talks to https://cloud.comfy.org over HTTPS // authenticated with `X-API-Key`. WebSocket / `/internal/logs` / // `/object_info` have no cloud equivalents — those throw `CLOUD_UNSUPPORTED` // so callers can degrade gracefully. // // Architecture (isCloudMode() dispatch + parallel cloud-client.ts) was // originally shipped by @picoSols in `picoSols/comfyui-cloud-mcp@7a812069` // (2026-03-25). This is a port adapted to our config helpers + error types, // with `fetchImage` / `uploadImageHttp` added so our existing image tools // keep working in cloud mode. import { getApiKey, getCloudUrl } from "../config.js"; // Shared with the headless client on purpose: one ceiling, one delivery-doubt // rule. The cloud client is a parallel implementation, and the last two rounds of // hardening (the 0.50.11 timeout ceiling, the delivery-doubt disclosure) reached // only its twin — which is exactly how a parallel implementation rots. import { comfyHttpTimeoutSeconds, defaultComfyTimeoutSignal, deliveryDoubt, isTimeoutAbort, } from "./fetch.js"; import { BoundedResponseError, clampViewResponseBytes, MAX_HISTORY_RESPONSE_BYTES, readResponseBodyBounded, } from "./bounded-response.js"; import { bodyPrefixOf, describeStatus, readComfyJson } from "./json-guard.js"; import { splitUploadTarget } from "./upload-target.js"; import { ComfyUIError, ConnectionError, describeFetchFailure } from "../utils/errors.js"; import { logger } from "../utils/logger.js"; function cloudUrl(path) { const base = getCloudUrl().replace(/\/+$/, ""); const p = path.startsWith("/") ? path : `/${path}`; return `${base}${p}`; } function authHeaders(extra = {}) { return { "X-API-Key": getApiKey(), ...extra }; } async function cloudFetch(path, init) { const url = cloudUrl(path); const baseHeaders = init?.skipJsonContentType ? {} : { "Content-Type": "application/json" }; const headers = { ...authHeaders(baseHeaders), ...(init?.headers ?? {}), }; const method = String(init?.method ?? "GET").toUpperCase(); // A CEILING, not a policy — the same one comfyuiFetch got in 0.50.11, which this // client never inherited. Without it a cloud request that never answers hangs the // tool call forever: no timeout, no error, nothing for the caller to act on. A // caller's own signal always wins. const signal = init?.signal ?? defaultComfyTimeoutSignal(); try { const res = await fetch(url, { ...init, headers, signal }); if (!res.ok) { // unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the // HTTP status is reported either way, so an unreadable body costs detail in the // text, never a wrong conclusion. Verified there is no branch on this value. const body = await res.text().catch(() => ""); // #1191 — same raw-500-byte exposure as the local /prompt builder, on the // path that carries a CLOUD API KEY. A gateway that reflects the request // can echo it in the body it answers with, and this string lands in a tool // result users paste into bug reports. bodyPrefixOf redacts by shape as // well as by known value and withholds entirely when it cannot substitute // safely; describeStatus does the same for the reason phrase. throw new ComfyUIError(`Cloud API error: ${describeStatus(res.status, res.statusText)} — ${bodyPrefixOf(body)}`, "CLOUD_API_ERROR", { url, status: res.status }); } return res; } catch (err) { if (err instanceof ComfyUIError) throw err; // Our own ceiling firing is not the same event as a caller's abort, and must // not be reported as one. Only rewrite when WE supplied the signal. if (init?.signal === undefined && isTimeoutAbort(err)) { throw new ConnectionError(`No reply from Comfy Cloud within ${comfyHttpTimeoutSeconds()}s — while requesting ` + `${url} (${method}).` + deliveryDoubt(undefined, method) + ` Raise COMFYUI_MCP_HTTP_TIMEOUT_S if the cloud is simply slow.`); } // "Failed to reach" was a claim, not an observation. A transport error on a // POST to /api/prompt does not establish that the submission never landed — // and a blind retry there bills a SECOND cloud render. Same three states as // the headless client: delivered, definitely not delivered, unknown. const { message, code } = describeFetchFailure(err); const wrapped = new ConnectionError(`Could not complete the ${method} to Comfy Cloud at ${url}: ${message}.` + deliveryDoubt(code, method)); if (code) wrapped.code = code; throw wrapped; } } export async function enqueuePrompt(workflow, extraData, opts) { logger.info("Cloud: submitting workflow to Comfy Cloud"); const body = { prompt: workflow }; if (extraData && Object.keys(extraData).length > 0) { body.extra_data = extraData; } if (opts?.partialExecutionTargets?.length) { body.partial_execution_targets = [...opts.partialExecutionTargets]; } const res = await cloudFetch("/api/prompt", { method: "POST", body: JSON.stringify(body), }); const data = (await res.json()); logger.info("Cloud: workflow submitted", { prompt_id: data.prompt_id }); return { prompt_id: data.prompt_id, queue_remaining: data.number }; } export async function getHistory(promptId, options = {}) { if (!promptId) { logger.warn("Cloud: getHistory() without prompt_id returns {} (no global history endpoint)"); return {}; } const path = `/api/history_v2/${promptId}`; const res = await cloudFetch(path, options.signal ? { signal: options.signal } : {}); const data = await readComfyJson(res, { url: path, maxBytes: MAX_HISTORY_RESPONSE_BYTES, bodyTimeoutMs: Math.round(comfyHttpTimeoutSeconds() * 1000), signal: options.signal, }); if (data && typeof data === "object") { if (data[promptId]) { return data; } return { [promptId]: data }; } return {}; } /** HTTP statuses that mean "this cloud has no such endpoint" rather than "the * request failed". Only these may fall back to the placeholder. */ const ENDPOINT_ABSENT_STATUSES = new Set([404, 405, 501]); /** * #1070 — the catch here used to swallow EVERYTHING and return an invented * device, so an expired API key, a 402, a DNS failure, a timeout and a network * partition all came back looking like a successful health check. * * That mattered more than a mislabelled outcome usually does, for two reasons. * `get_system_stats (action:"health")` is the remedy our own error messages send * people to — it is named in describeComfyFetchFailure, in the timeout text, and * in this file's messages — so in cloud mode the remedy could not return a * negative result. And health-check.ts / local-models-fallback.ts both call this * as a REACHABILITY PROBE, which the placeholder answered "yes" to unconditionally. * * It also fabricated data rather than merely mislabelling it: `vram_total: 0` is * not a reading, and a caller sizing a job against it is reading an invention. * * Three states now, not two: * - answered → return what the endpoint said * - endpoint ABSENT (404) → the placeholder, which is the case this was written * for, but named so a reader can see it is not a reading * - could not ask → propagate, because that is a real failure */ export async function getSystemStats() { try { const res = await cloudFetch("/system_stats"); return (await res.json()); } catch (err) { const status = err instanceof ComfyUIError && err.details && typeof err.details === "object" ? err.details.status : undefined; if (typeof status !== "number" || !ENDPOINT_ABSENT_STATUSES.has(status)) { // Auth, billing, transport, timeout — a real failure. The caller asked // whether the cloud is reachable; inventing a GPU is not an answer. throw err; } logger.info("Cloud: /system_stats not implemented by this cloud, returning placeholder", { status, }); return { system: { os: "cloud", python_version: "cloud", embedded_python: false, comfyui_version: "cloud", }, devices: [ { // Named, not silent: these are placeholders, not measurements. The // "Comfy Cloud GPU" prefix is kept so anything matching on it still does. name: "Comfy Cloud GPU (no stats endpoint — values below are placeholders, not readings)", type: "cloud", index: 0, vram_total: 0, vram_free: 0, torch_vram_total: 0, torch_vram_free: 0, }, ], }; } } export async function getQueue() { return { queue_running: [], queue_pending: [] }; } export async function interrupt(promptId) { if (!promptId) { throw new ComfyUIError("Cancel requires a prompt_id in Comfy Cloud mode (no concept of 'current job').", "CLOUD_UNSUPPORTED"); } await cloudFetch(`/api/job/${promptId}/cancel`, { method: "POST" }); logger.info("Cloud: job cancelled", { prompt_id: promptId }); } export async function deleteQueueItem(_id) { throw new ComfyUIError("delete_queue_item is not supported in Comfy Cloud mode. Use queue (action:\"cancel\") with a prompt_id.", "CLOUD_UNSUPPORTED"); } export async function clearQueue() { throw new ComfyUIError("queue (action:\"clear\") is not supported in Comfy Cloud mode.", "CLOUD_UNSUPPORTED"); } export async function getJobStatus(promptId) { const res = await cloudFetch(`/api/job/${promptId}/status`); return (await res.json()); } export async function getObjectInfo() { throw new ComfyUIError("create_workflow (action:\"node_info\"), which reads /object_info, is not available in Comfy Cloud mode.", "CLOUD_UNSUPPORTED"); } const COMMON_SAMPLERS = [ "euler", "euler_ancestral", "heun", "heunpp2", "dpm_2", "dpm_2_ancestral", "lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral", "dpmpp_sde", "dpmpp_sde_gpu", "dpmpp_2m", "dpmpp_2m_sde", "dpmpp_2m_sde_gpu", "dpmpp_3m_sde", "dpmpp_3m_sde_gpu", "ddpm", "lcm", "ddim", "uni_pc", "uni_pc_bh2", ]; const COMMON_SCHEDULERS = [ "normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform", "beta", ]; export async function getSamplers() { return [...COMMON_SAMPLERS]; } export async function getSchedulers() { return [...COMMON_SCHEDULERS]; } export async function getCheckpoints() { throw new ComfyUIError("Listing local checkpoints is not supported in Comfy Cloud mode.", "CLOUD_UNSUPPORTED"); } export async function getLoRAs() { throw new ComfyUIError("Listing local LoRAs is not supported in Comfy Cloud mode.", "CLOUD_UNSUPPORTED"); } export async function getVAEs() { throw new ComfyUIError("Listing local VAEs is not supported in Comfy Cloud mode.", "CLOUD_UNSUPPORTED"); } export async function getUpscaleModels() { throw new ComfyUIError("Listing local upscale models is not supported in Comfy Cloud mode.", "CLOUD_UNSUPPORTED"); } export async function getLogs() { throw new ComfyUIError("Server logs are not available in Comfy Cloud mode.", "CLOUD_UNSUPPORTED"); } export async function fetchImage(filename, type = "output", subfolder = "", options = {}) { const params = new URLSearchParams({ filename, type, subfolder }); const url = cloudUrl(`/api/view?${params.toString()}`); // /api/view returns either bytes or a 302 to a signed URL, which is why this // does NOT go through cloudFetch: it needs redirect-following and raw bytes // rather than the JSON envelope. That exemption is also how it kept the bare, // signal-less `fetch` that #1069 removed from cloudFetch one call over — a // download from a wedged cloud hung the tool call with nothing to act on. let res; try { res = await fetch(url, { headers: authHeaders(), redirect: "follow", signal: options.signal ?? defaultComfyTimeoutSignal(), }); } catch (err) { if (isTimeoutAbort(err)) { throw new ConnectionError(`No reply from Comfy Cloud within ${comfyHttpTimeoutSeconds()}s while downloading ` + `"${filename}" — ${url} (GET). Nothing was learned about the image from this: a ` + `timeout is not a "not found". Raise COMFYUI_MCP_HTTP_TIMEOUT_S if the cloud is ` + `simply slow.`); } // A read, so no delivery doubt applies — re-requesting an image is safe. const { message, code } = describeFetchFailure(err); const wrapped = new ConnectionError(`Could not download "${filename}" from Comfy Cloud at ${url}: ${message}.`); if (code) wrapped.code = code; throw wrapped; } if (!res.ok) { throw new ComfyUIError(`Cloud /api/view ${res.status} for "${filename}"`, "CLOUD_API_ERROR", { status: res.status }); } const contentType = res.headers.get("content-type") ?? "image/png"; const mimeType = contentType.split(";")[0].trim(); const limit = clampViewResponseBytes(options.maxBytes); let bytes; try { // Cloud /api/view is reached by every automatic history availability probe. // Read it through the same bounded stream consumer as the headless client; // arrayBuffer() would let one oversized response become an oversized base64 // tool result before the caller can reject it. bytes = await readResponseBodyBounded(res, Math.round(comfyHttpTimeoutSeconds() * 1000), limit, options.signal); } catch (error) { if (error instanceof BoundedResponseError) { if (error.kind === "too-large") { throw new ComfyUIError(`Cloud /api/view response for "${filename}" exceeds the ${limit / 1024 ** 2} MB safety limit.`, "VIEW_TOO_LARGE", { filename, maxBytes: limit }); } throw new ConnectionError(`No reply from Comfy Cloud within ${comfyHttpTimeoutSeconds()}s while downloading ` + `"${filename}" — the response body did not finish in time.`); } throw error; } const base64 = bytes.toString("base64"); return { base64, mimeType }; } export async function uploadImageHttp(filename, data, mimeType = "image/png", overwrite = true) { // #946 — same split as client.ts's uploadImageHttp: a `filename` carrying a // path ("assets/clip.mp4") is a subfolder request, and the API takes that as // its own form field. This twin kept the original defect until now — the rot // the header above warns about. const { subfolder, name } = splitUploadTarget(filename); const formData = new FormData(); const blob = new Blob([data], { type: mimeType }); formData.append("image", blob, name); formData.append("type", "input"); formData.append("overwrite", String(overwrite)); if (subfolder) formData.append("subfolder", subfolder); // multipart sets its own Content-Type; skip the JSON header. const res = await cloudFetch("/upload/image", { method: "POST", body: formData, skipJsonContentType: true, }); return res.json(); } export function getComfyUIPath() { return undefined; } //# sourceMappingURL=cloud-client.js.map