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),
453 lines • 23.4 kB
JavaScript
import { z } from "zod";
import { detectInstallMode } from "../services/self-update.js";
import { stampAgentIdentity } from "../services/agent-identity.js";
// Issue reporting. For OUR repos (comfyui-mcp / comfyui-mcp-panel) this FILES the
// issue through the async AI-triage Worker: submit (with the reporter's versions)
// → get an instant version-ack + job_id → poll /status/<job_id> until the triage
// agent CLOSEs the job, then surface its result (a new issue, a dedup, a reopen,
// or an "already fixed — upgrade" answer with the fixing PR + version). For
// third-party repos, or when the Worker is unreachable, it falls back to a
// PREFILLED GitHub "new issue" URL the user can review and submit in one click.
const DEFAULT_REPO = "artokun/comfyui-mcp";
// Repos the intake Worker is allowed to file into (owner is fixed server-side).
const OUR_OWNER = "artokun";
const OUR_REPO_NAMES = new Set(["comfyui-mcp", "comfyui-mcp-panel"]);
// Defaults mirror the report-bug SKILL. Both are overridable via env; the client
// key is a soft anti-spam gate (not a real secret — the GitHub token is
// server-side in the Worker).
const DEFAULT_WORKER_URL = "https://comfyui-mcp-issue-worker.artokun.workers.dev";
const DEFAULT_CLIENT_KEY = "9b6f2abf09b64006dc6e033f59d2dc8112e34d8347a923c2";
/** The User-Agent every report-bug request sends (#937). Named on purpose: a
* default runtime signature is a WAF disposition we do not control. */
export const REPORT_UA = "comfyui-mcp-report-bug/1.0";
export function normalizeRepo(input) {
const repo = (input || DEFAULT_REPO)
.trim()
.replace(/^https?:\/\/github\.com\//i, "")
.replace(/\.git$/i, "")
.replace(/^\/+|\/+$/g, "");
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
throw new Error(`invalid repo "${repo}" — expected owner/name`);
}
return repo;
}
export function buildIssueUrl(repo, title, body, labels) {
const params = new URLSearchParams({ title, body });
if (labels?.length)
params.set("labels", labels.join(","));
return `https://github.com/${repo}/issues/new?${params.toString()}`;
}
export function isOurRepo(repo) {
const [owner, name] = repo.split("/");
// Case-insensitive: git config / this repo use "Artokun" with a capital A,
// which must still be recognized as ours (not misrouted as third-party).
return owner?.toLowerCase() === OUR_OWNER && OUR_REPO_NAMES.has(name?.toLowerCase());
}
/**
* Submit to the async AI-triage Worker and poll /status/<job_id> until the job
* CLOSEs or the budget runs out. Injectable fetch/sleep for testing.
* THROWS only on a submit failure (so the caller uses the prefilled fallback);
* a still-running job at the budget returns { kind: "pending" }.
*/
export async function submitAndPoll(opts) {
const doFetch = opts.fetchImpl ?? fetch;
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
// Poll past the WORKER's own wall-clock budget, not just the typical triage
// time — the worker allows itself up to 240 s (TRIAGE_WALL_CLOCK_MS) and a
// thorough dedup (several searches + candidate reads) now uses it. A shorter
// client budget returns "still triaging" for work that was about to finish,
// costing the reporter the fix-version/duplicate answer. ~95 * 3 s ≈ 285 s.
const maxPolls = opts.maxPolls ?? 95;
const pollDelayMs = opts.pollDelayMs ?? 3000;
const timeoutMs = opts.timeoutMs ?? 15000; // per-request cap (not the whole job)
// Keep the abort active through BOTH the fetch AND the body parse — otherwise
// a hung `.json()` after headers arrive would never time out.
const withTimeout = async (fn) => {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeoutMs);
try {
return await fn(ac.signal);
}
finally {
clearTimeout(t);
}
};
// `accepted` flips true the instant the worker returns a 2xx — from that point
// the worker HAS the report and is filing autonomously, so a later body
// stall/parse failure must NOT throw into a prefill (that would double-file).
let accepted = false;
let submit;
try {
submit = await withTimeout(async (signal) => {
const r = await doFetch(opts.workerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Client-Key": opts.clientKey,
// #937 — an EXPLICIT, named signature rather than whatever the runtime
// advertises. Cloudflare fronts this endpoint and bans some default
// client UAs outright: a Python urllib POST gets 403 / `error code:
// 1010` while the byte-identical request with an ordinary UA succeeds
// seconds later. Node fetch happens to be allowed today — a WAF
// disposition we neither control nor chose. Naming ourselves makes the
// path deterministic instead of incidentally lucky.
"User-Agent": REPORT_UA,
Accept: "application/json",
// Opt into the async AI-triage contract. Absent this header the worker
// runs its legacy synchronous dumb-file path (old clients stay working).
"X-Triage-Async": "1",
},
body: JSON.stringify({
repo: opts.repoName,
title: opts.title,
body: opts.body,
labels: opts.labels,
reporter_versions: opts.reporterVersions ?? {},
}),
signal,
});
if (!r.ok)
throw new Error(`worker submit failed: HTTP ${r.status}`);
accepted = true; // 2xx → the worker took the report
return (await r.json());
});
}
catch (err) {
// A 2xx whose body stalled/was malformed: the worker still took it and is
// filing — surface pending (no job_id, no ack) so the caller does NOT
// prefill. A pre-2xx failure (network / non-OK) rethrows → caller prefills.
if (accepted)
return { kind: "pending", ack: {} };
throw err;
}
const ack = {
job_id: submit.job_id,
versions: submit.versions,
up_to_date: submit.up_to_date,
upgrade_hint: submit.upgrade_hint,
};
// A synchronous worker (legacy / very fast) may return the result inline.
if (isClosedFrame(submit)) {
const inline = frameToResult(submit);
return inline ? { kind: "closed", ack, result: inline } : { kind: "unfiled", ack };
}
const jobId = submit.job_id;
// Accepted (2xx) but no job_id and no inline result → we can't poll, but the
// worker took it and is filing. Surface pending (never prefill → no double).
if (!jobId)
return { kind: "pending", ack };
const base = opts.workerUrl.replace(/\/+$/, "");
for (let i = 0; i < maxPolls; i++) {
await sleep(pollDelayMs);
let parsed;
try {
parsed = await withTimeout(async (signal) => {
const r = await doFetch(`${base}/status/${jobId}`, {
signal,
headers: { "X-Client-Key": opts.clientKey, "User-Agent": REPORT_UA, Accept: "application/json" },
});
if (!r.ok)
throw new Error(`poll failed: HTTP ${r.status}`);
return (await r.json());
});
}
catch {
// A transient poll error does NOT mean the job failed — the worker keeps
// running. Keep polling; only a real terminal frame or the budget ends it.
continue;
}
if (isClosedFrame(parsed) || isTerminalError(parsed)) {
const result = frameToResult(parsed);
// Terminal WITH a usable issue/answer → closed; terminal WITHOUT one
// (unfiled_needs_manual / hard error) → the worker gave up filing, so the
// caller may safely prefill (nothing was written → no double-file).
return result ? { kind: "closed", ack, result } : { kind: "unfiled", ack };
}
// else PENDING / INVESTIGATING → keep polling
}
// Still running at the budget: the worker will finish autonomously — do NOT
// prefill (that duplicates). Surface the ack + job id.
return { kind: "pending", ack, job_id: jobId };
}
/** A frame is TERMINAL (job finished) iff CLOSED, a legacy done, or a legacy
* sync response that carries a url with no lifecycle field at all. */
function isClosedFrame(frame) {
return (frame.state === "CLOSED" ||
frame.status === "done" ||
(frame.state == null && frame.status == null && !!frame.url));
}
/** Flatten a terminal frame to a TriageResult, or null if it carries no usable
* issue link / answer (→ the caller treats it as "unfiled"). */
function frameToResult(frame) {
const p = frame.payload;
const issueUrl = p?.issue?.url ?? frame.url;
const issueNumber = p?.issue?.number ?? frame.number;
// "Usable" = there is a real issue link, OR it is the advise-upgrade answer
// (no created issue, but real fix guidance). A terminal frame WITHOUT either —
// e.g. unfiled_needs_manual, which still carries an agent_message — is NOT a
// success: return null so the caller treats it as "unfiled" and prefills. (Do
// NOT key this on agent_message; the worker sets one even on failure.)
const usable = !!issueUrl || p?.action_taken === "advised_upgrade";
if (!usable)
return null;
return {
url: issueUrl,
number: issueNumber,
deduped: p?.deduped,
classification: p?.classification,
action_taken: p?.action_taken,
fixed_in_version: p?.fixed_in_version ?? null,
fix_pr_url: p?.fix_pr_url ?? null,
recommend_upgrade: p?.recommend_upgrade,
agent_message: p?.agent_message,
possible_duplicate: p?.possible_duplicate,
// Older workers omit this entirely — keep it absent rather than [] so the
// surface distinguishes "no cluster" from "worker predates clustering".
related_issues: Array.isArray(p?.related_issues) ? p.related_issues : undefined,
};
}
function isTerminalError(frame) {
return frame.status === "error" && !frame.url && !frame.payload;
}
/**
* The RUNNING comfyui-mcp version, snapshotted once at module load.
*
* `detectInstallMode().currentVersion` reads the package on disk, which answers
* a different question: what is INSTALLED. Those agree until someone updates in
* place, and then every issue filed by the still-running old process is stamped
* with the new version — the exact misreport #846 was opened for, reintroduced
* through the auto-detect fallback after round 9 fixed the explicit path (codex
* gate P1).
*
* Reading at load time is not a perfect proxy for "the code executing right
* now", but it is bounded by process start, which is the property that matters:
* it cannot drift underneath a long-lived process the way a report-time read
* does.
*/
const RUNNING_MCP_VERSION = (() => {
try {
return detectInstallMode().currentVersion ?? undefined;
}
catch {
return undefined;
}
})();
/** The running comfyui-mcp version (best-effort; undefined if undetectable). */
function detectMcpVersion() {
return RUNNING_MCP_VERSION;
}
/** A semver body, with the optional `v` prefix stripped by the capture group. */
const SEMVER_BODY = String.raw `v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)`;
/**
* How each version is LABELLED in the ENVIRONMENT line.
*
* `comfyui-mcp-panel 0.11.38` is not readable as the mcp version because the label
* has to be followed by a separator and then A VERSION, and the word `panel` sits
* in between — the version is simply not adjacent to the mcp label. Stated plainly
* because two stronger-looking guards were tried here and both turned out to change
* no input at all: negative lookaheads on the label, and forbidding the hyphen as a
* separator. Neither was falsifiable, so neither is kept as protection; the covering
* test asserts the BEHAVIOUR (`comfyui-mcp-panel 0.11.38` yields no mcp version)
* rather than the mechanism.
*/
export const MCP_VERSION_LABEL = String.raw `(?:comfyui-mcp|mcp)`;
/** Same, for the sidebar panel. The longer alias is listed first so it wins. */
export const PANEL_VERSION_LABEL = String.raw `(?:comfyui-mcp-panel|panel)`;
/**
* Reduce a caller-supplied version to the VERSION IN IT, or undefined.
*
* These strings are copied by an agent out of a prose ENVIRONMENT line, and that
* line grew a qualifying clause (#846: "comfyui-mcp 0.48.18 (this process is
* RUNNING 0.48.18; 0.49.5 is now installed on disk …)"). Passed through whole, the
* triage worker's version match sees a sentence instead of a version and cannot
* tell the user that upgrading already fixes their bug — the single most common
* resolution, and the outcome #846 was filed to protect.
*
* THE LABEL IS TRIED FIRST, and it is what makes this correct rather than merely
* lucky (codex gate round 2). Two simpler rules were tried and both mis-read a
* realistic input:
* - anchored at position 0: an agent pasting the natural segment
* ("comfyui-mcp 0.48.18 …") matched nothing, fell through to a fresh disk read,
* and reported the INSTALLED version — reintroducing #846 through its own fix;
* - first semver ANYWHERE: the ENVIRONMENT line names `torch 2.7.1 · python
* 3.12.3` BEFORE `comfyui-mcp 0.48.18`, so an agent pasting the whole line —
* which the tool description tells it to do — reported TORCH's version as the
* reporter's. Wrong in a way nobody downstream could detect.
* Reading the label is the only rule that survives the input the tool actually asks
* for.
*/
/** Release channels that are legitimate version values without carrying a digit. */
const CHANNEL_TAGS = new Set([
"nightly",
"dev",
"development",
"edge",
"canary",
"beta",
"alpha",
"rc",
"latest",
"main",
"master",
"local",
]);
/**
* Is this token a VERSION, as opposed to a word that merely stands where one was
* expected? Semver wins outright; otherwise it must carry a digit (`0.49.6-dev3`,
* `2026.08.04`) or be a recognised channel name.
*
* The point is what it REJECTS: `unknown`, `unspecified`, `none`, and any stray word
* an agent might drop in ("comfyui-mcp is broken"). Those are not versions, and
* forwarding them would hand the triage worker something unmatchable while looking
* like a confident answer — the caller falls through to detection instead, which is
* the right move precisely when the agent had nothing.
*/
function asVersionToken(rawToken) {
// The ENVIRONMENT line ends in a full stop, so the last component's token arrives
// as `0.11.38.` — which passes every shape test and is then reported with the
// trailing dot attached, defeating the exact-version match downstream. No version
// ends in punctuation, so stripping it is unambiguous.
const token = rawToken.replace(/[.,;:)\]}]+$/, "");
if (token === "")
return undefined;
const semver = new RegExp(`^${SEMVER_BODY}$`, "i").exec(token);
if (semver)
return semver[1];
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$/.test(token))
return undefined;
if (/\d/.test(token))
return token;
return CHANNEL_TAGS.has(token.toLowerCase()) ? token : undefined;
}
export function normalizeReportedVersion(raw, label) {
if (typeof raw !== "string")
return undefined;
const trimmed = raw.trim();
if (trimmed === "")
return undefined;
// 1. LABELLED — survives a whole ENVIRONMENT line, other components' versions and
// all. The separator covers both `comfyui-mcp 0.49.6` and `mcp=0.49.6`.
//
// The value is captured as a TOKEN and validated after, not required to be
// semver in the pattern (codex gate round 3). Requiring semver here meant a
// non-semver running build — `comfyui-mcp nightly (this process is RUNNING
// nightly; 0.49.6 is now installed on disk …)` — matched nothing, fell through
// to a disk read, and reported the INSTALLED 0.49.6 as the running version.
// That is #846 exactly, for the one user whose version needed the most care.
//
// ONE named filler word may stand between the label and the number (codex gate
// round 4): `MCP version: 0.48.18` puts `version` where the value goes, and
// taking only the adjacent token dropped a perfectly good reading straight into
// the disk-read fallback — #846 again.
//
// It is a NAMED filler and not "scan the next few tokens" (codex gate round 6).
// Scanning walked straight over an indeterminate value into the NEXT
// component's: `comfyui-mcp unknown · panel 0.11.38.` returned the PANEL's
// version as the mcp one. An mcp version that cannot be determined has to stay
// undetermined — that is the whole rule this cluster is about — so anything that
// is neither the filler nor a version stops the search here.
//
// The left boundary keeps `other-mcp 1.2.3` from being read as ours.
const after = new RegExp(`(?<![A-Za-z0-9-])${label}[\\s:=]+(?:versions?[\\s:=]+)?([^\\s·,;)]+)`, "i").exec(trimmed);
if (after) {
const value = asVersionToken(after[1]);
if (value)
return value;
}
// 2. LEADING — the caller sent the version itself, possibly with the #846 drift
// clause after it. The clause names a NEWER installed version second, so
// taking the leading one keeps the RUNNING build, which is what the report is
// about. Nothing in the ENV line begins with a bare version, so this cannot
// pick up another component's.
const leading = new RegExp(`^${SEMVER_BODY}`).exec(trimmed);
if (leading)
return leading[1];
// 3. THE WHOLE STRING as a version token — the caller sent just `nightly`, with no
// label. Preserved for the same reason as above: discarding it would send the
// caller to a disk read, i.e. the version-swap in a different disguise.
return asVersionToken(trimmed);
}
/**
* The intake worker truncates a submitted body at 60_000 chars (index.ts:
* MAX_BODY_LEN) before anything is stored or filed.
*
* The identity stamp is the LAST thing in the body, so an oversized report — a
* pasted log, the diff the report-bug skill asks for — would have its
* attribution cut off server-side, silently, and precisely on the reports with
* the most in them. Trim the caller's own text by what the stamp costs instead:
* the worker was going to cut that tail anyway, so the content lost is the same
* either way, and the attribution survives.
*
* MEASURE THE BODY THAT SHIPS, never a stand-in for it. The first version
* budgeted `stampAgentIdentity("")` — the stamp's own width — and trimmed the
* caller's RAW text to fit. But stamping also rewrites every quoted marker the
* body carries (`<!-- reporter-agent:` → `<!-- quoted-reporter-agent:`, +7 chars
* each), so a body quoting other issues came out 7·n OVER the cap and the worker
* cut off the very stamp this exists to protect — 88 quoted markers removed it
* outright (round-3 merge gate, P1). The covering test was marker-free, so it
* passed either way: blind by construction to the one interaction that mattered.
*
* So the loop below asks the real question — "how long is the body I am about to
* send?" — and shortens by the actual overflow. One correction is provably
* enough (removing the overflow can only reduce the number of markers retained,
* so the expansion cannot grow), and the bound is there so a future change to
* the stamp cannot turn this into a spin.
*
* Exported for the covering test — nothing else calls it.
*/
export const WORKER_MAX_BODY_LEN = 60_000;
export function fitUnderWorkerCap(body, identity) {
if (!identity || stampAgentIdentity(body, identity).length <= WORKER_MAX_BODY_LEN)
return body;
const note = "\n\n…[trimmed to keep the reporting-agent stamp]";
// Measure the stamp against THIS identity rather than assuming a width — the
// model name is provider-supplied and unbounded in principle.
let room = WORKER_MAX_BODY_LEN - stampAgentIdentity("", identity).length - note.length;
for (let i = 0; i < 8 && room > 0; i++) {
const candidate = body.slice(0, room) + note;
const shipped = stampAgentIdentity(candidate, identity).length;
if (shipped <= WORKER_MAX_BODY_LEN)
return candidate;
room -= shipped - WORKER_MAX_BODY_LEN;
}
// Nothing of the caller's text fits alongside the stamp. Keeping the note
// alone is the honest outcome: a report that says it was trimmed, correctly
// attributed, beats a full one attributed to nobody.
return note;
}
function jsonResult(obj) {
return {
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
};
}
export const REPORTING_DISABLED_NOTICE = "comfyui-mcp and the Agent Panel receive limited maintenance for critical local-panel fixes. " +
"Issue reporting remains disabled: nothing was filed and no service was contacted. " +
"Continue helping with the task using the available tools. Do not retry this call, " +
"submit a report through another tool, or collect information for automatic reporting.";
export function registerReportIssueTools(server) {
server.tool("report_issue", "DISABLED — issue reporting remains off during limited maintenance. This tool files NOTHING and contacts no service. It returns the reporting status, without a prefilled issue link. Kept registered for older prompts and skills; continue helping with the task, without retrying or filing through another tool.", {
title: z.string().min(1).describe("Ignored — retained for compatibility with callers that still pass it."),
body: z.string().min(1).describe("Ignored — retained for compatibility with callers that still pass it."),
repo: z.string().optional().describe("Ignored."),
labels: z.array(z.string()).optional().describe("Ignored."),
mcp_version: z.string().optional().describe("Ignored."),
panel_version: z.string().optional().describe("Ignored."),
no_file: z.boolean().optional().describe("Ignored."),
}, async (args) => {
// Reporting stays offline regardless of worker environment configuration.
return jsonResult({
archived: false,
filed: false,
reporting_enabled: false,
repo: normalizeRepo(args.repo),
official: {
comfy_agent: "https://comfy.org/agent",
docs: "https://docs.comfy.org/agent-tools",
comfy_mcp: "https://comfy.org/mcp/",
repo: "https://github.com/Comfy-Org/comfy-mcp",
},
note: REPORTING_DISABLED_NOTICE,
});
});
}
//# sourceMappingURL=report-issue.js.map