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),
335 lines • 17.5 kB
JavaScript
import { parseComfyUIUrl } from "./comfyui-url.js";
import { tr, processLocale } from "../i18n/index.js";
import { padTo } from "../i18n/terminal-layout.js";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 9100;
/**
* Parse transport-related CLI flags and env vars. stdio is the default so
* existing Claude Code / Desktop users are unaffected; --http opts into the
* streamable-HTTP server. Supports both "--flag value" and "--flag=value".
*
* Precedence: explicit CLI flag > env var > built-in default.
*/
export function parseCliArgs(argv, env = process.env) {
const args = argv.slice(2);
let help = false;
let transport = env.MCP_TRANSPORT === "http" ? "http" : "stdio";
// 0.50.0 flip: full is the default, compact is the opt-in. Only the exact
// string "compact" selects it, so a typo'd value falls back to the default
// exactly as a typo'd value used to fall back to compact.
let toolMode = env.COMFYUI_MCP_TOOL_MODE === "compact" ? "compact" : "full";
let host = env.MCP_HOST ?? DEFAULT_HOST;
let port = env.MCP_PORT ? Number(env.MCP_PORT) : DEFAULT_PORT;
let panelOrchestrator = env.COMFYUI_MCP_PANEL_ORCHESTRATOR === "1" ||
env.COMFYUI_MCP_PANEL_ORCHESTRATOR === "true";
let token = env.COMFYUI_MCP_HTTP_TOKEN?.trim() || undefined;
let tunnel = env.MCP_TUNNEL === "1" || env.MCP_TUNNEL === "true";
let allowUnauthenticated = env.COMFYUI_MCP_ALLOW_UNAUTH === "1" || env.COMFYUI_MCP_ALLOW_UNAUTH === "true";
let insecureBridge = env.COMFYUI_MCP_INSECURE_BRIDGE === "1" || env.COMFYUI_MCP_INSECURE_BRIDGE === "true";
let comfyuiUrl;
let setupAgent;
let setupDryRun = false;
let launcherAction;
let toolModeExplicit = env.COMFYUI_MCP_TOOL_MODE === "compact" || env.COMFYUI_MCP_TOOL_MODE === "full";
const valueOf = (current, inline, i) => {
if (current.includes("="))
return [current.slice(current.indexOf("=") + 1), i];
return [args[i + 1] ?? "", i + 1];
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "launcher") {
const next = args[i + 1];
if (next && !next.startsWith("-")) {
launcherAction = ["install", "uninstall", "status", "run"].includes(next)
? next
: "";
i += 1;
}
else {
launcherAction = "";
}
}
else if (a === "connect") {
// `comfyui-mcp connect <comfyui-url>` — one-command local connect: run the
// panel orchestrator (subscription auth, no API key) and point it at the
// given ComfyUI via COMFYUI_URL. The URL is the next positional token (a
// following "--flag" is left to be parsed normally). With no URL it's just
// sugar for --panel-orchestrator (local default / inherited COMFYUI_URL).
panelOrchestrator = true;
const next = args[i + 1];
if (next && !next.startsWith("-")) {
comfyuiUrl = next;
i += 1; // consume the URL token
}
}
else if (a === "--help" || a === "-h") {
help = true;
}
else if (a === "--http") {
transport = "http";
}
else if (a === "--stdio") {
transport = "stdio";
}
else if (a === "--transport" || a.startsWith("--transport=")) {
const [v, ni] = valueOf(a, "--transport", i);
transport = v === "http" ? "http" : "stdio";
i = ni;
}
else if (a === "--host" || a.startsWith("--host=")) {
const [v, ni] = valueOf(a, "--host", i);
if (v)
host = v;
i = ni;
}
else if (a === "--port" || a.startsWith("--port=")) {
const [v, ni] = valueOf(a, "--port", i);
if (v)
port = Number(v);
i = ni;
}
else if (a === "--panel-orchestrator") {
panelOrchestrator = true;
}
else if (a === "--token" || a.startsWith("--token=")) {
const [v, ni] = valueOf(a, "--token", i);
if (v)
token = v;
i = ni;
}
else if (a === "--tunnel") {
tunnel = true;
}
else if (a === "--allow-unauthenticated-non-loopback") {
allowUnauthenticated = true;
}
else if (a === "--insecure-bridge") {
insecureBridge = true;
}
else if (a === "--compact") {
toolMode = "compact";
toolModeExplicit = true;
}
else if (a === "--full") {
toolMode = "full";
toolModeExplicit = true;
}
else if (a === "--tool-mode" || a.startsWith("--tool-mode=")) {
const [v, ni] = valueOf(a, "--tool-mode", i);
// Inverted with the 0.50.0 default: only the exact string "compact"
// selects compact, so `--tool-mode bogus` falls back to the default, which
// is what it always did — the default just changed under it.
toolMode = v === "compact" ? "compact" : "full";
toolModeExplicit = true;
i = ni;
}
else if (a === "setup") {
// `comfyui-mcp setup <agent>` — write the comfyui MCP entry into a
// non-Claude harness's config (hermes | openclaw | copilot) and exit.
const next = args[i + 1];
if (next && !next.startsWith("-")) {
setupAgent = next;
i += 1;
}
else {
setupAgent = ""; // present but missing the agent → index.ts prints usage
}
}
else if (a === "--lang" || a.startsWith("--lang=")) {
// Recognised here ONLY so its value token cannot be mistaken for a positional. The
// value itself is read by processLocale() straight off argv, because the locale has to
// be known before this parser runs — `--help` is rendered in it, and so is the error
// this parser's own callers print. Skipping the case entirely (which is what happened
// when `--lang` was introduced) leaves a bare locale code loose in argv, in the same
// stream where `connect` and `setup` read their operands off the next token.
const [, ni] = valueOf(a, "--lang", i);
i = ni;
}
else if (a === "--dry-run") {
setupDryRun = true;
}
else if (a === "--comfyui-url" || a.startsWith("--comfyui-url=")) {
// Also parsed by config.ts for the server itself; captured here so
// `setup` can embed it into the generated harness config.
const [v, ni] = valueOf(a, "--comfyui-url", i);
if (v)
comfyuiUrl = v;
i = ni;
}
}
// --tunnel implies the HTTP transport: a cloudflared quick tunnel needs an
// HTTP origin to point at. (Token auto-generation happens at startup, not
// here, to keep this parser pure/side-effect-free.)
if (tunnel)
transport = "http";
return {
help,
transport,
toolMode,
toolModeExplicit,
host,
port,
panelOrchestrator,
token,
tunnel,
allowUnauthenticated,
comfyuiUrl,
insecureBridge,
setupAgent,
setupDryRun,
launcherAction,
};
}
/**
* Propagate an explicitly-chosen tool mode into the process environment so the
* panel orchestrator's spawned MCP children inherit it (#667). Children read
* the mode from the ENV (resolveHttpLaneComfyToolMode for the HTTP lane,
* comfyuiSpawnEnv for the Ollama family) — a bare --full/--compact flag
* otherwise never reaches them, so `--full --panel-orchestrator` silently ran
* compact downstream. A DEFAULTED mode is deliberately NOT exported: unset env
* is how children tell "user chose nothing" from "user chose compact".
*
* The 0.50.0 flip changed what unset MEANS, and the distinction now carries
* real weight rather than being a tidiness argument. Before, unset resolved to
* compact everywhere, so exporting or not exporting a defaulted mode was
* unobservable. Now the top-level default is FULL while both child lanes still
* default to COMPACT on purpose — the HTTP lane shares its tool budget with ~91
* `panel_*` tools, and the Ollama lane feeds a small local model's 16k context
* (#97). Not exporting a defaulted mode is what lets those lanes keep their own
* default; exporting it would silently push the full surface into exactly the
* two places compact exists to protect. An EXPLICIT --full still reaches them,
* which is the documented way to opt a child lane in.
*/
export function exportExplicitToolMode(cli, env = process.env) {
if (cli.toolModeExplicit)
env.COMFYUI_MCP_TOOL_MODE = cli.toolMode;
}
/**
* Validate the `connect <comfyui-url>` positional before the orchestrator starts.
* The URL is exported as COMFYUI_URL and used to drive a (possibly remote)
* ComfyUI, so a bad value (e.g. `connect not-a-url`) must hard-fail instead of
* silently falling back to the local default — which would leave the startup
* banner claiming it's "Driving <bad url>" while actually targeting localhost.
*
* Reuses parseComfyUIUrl (the same parser COMFYUI_URL / --comfyui-url use) so the
* accept/reject rules stay identical. Returns a clear, actionable error message
* when the URL is invalid, or null when it parses cleanly.
*/
export function validateConnectUrl(url) {
try {
parseComfyUIUrl(url);
return null;
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
// Translated because this string IS the body of the fatal "cannot start" line the user
// reads in their terminal; translating the frame and leaving the reason English would
// produce a half-English sentence at the one moment the tool is refusing to run. The
// example URLs stay verbatim — they are things to copy, not things to read.
return tr("cli.connect_url_invalid", 'Invalid ComfyUI URL passed to `connect`: "{url}" ({reason}). ' +
"Pass a full http(s) URL, e.g. https://abcd-3000.proxy.runpod.net or http://127.0.0.1:8188.", { url, reason });
}
}
/** Cell column where `--help` descriptions start, and where the trailing `env:` notes start. */
const DESC_COL = 45;
const ENV_COL = 65;
/**
* One `--help` row: `left` is the flag/subcommand (never translated), `desc` the prose,
* `tail` an optional `env: …` note that keeps its own column.
*
* The gap used to be typed into the string literal, which is a layout that survives exactly
* as long as every description is English. This computes it from the DISPLAY WIDTH of
* whatever actually lands in the column, so a Korean description — two terminal cells per
* character — does not shove the `env:` notes off their column.
*
* The `3` minimum separator is not decoration: it is what keeps an entry whose description
* overruns its column readable, and it is what reproduces the previous hand-typed layout
* exactly (`--token` and `--tunnel` already sat three spaces from their `env:` note).
*
* Exported only so a test can hand it a Korean description. The whole screen renders English
* until a catalog exists, so nothing about the ASSEMBLED output can show whether the columns
* are computed in cells or in characters — the two agree for every string that exists today.
*/
export function helpRow(left, desc, tail) {
const body = tail ? padTo(desc, ENV_COL - DESC_COL, 3) + tail : desc;
return ` ${padTo(left, DESC_COL - 2)}${body}`.trimEnd();
}
/**
* Usage text for `--help` / `-h`.
*
* Every default shown is DERIVED by parsing an empty argv against an empty env,
* not restated. That is the whole point: a hand-written default is a claim that
* drifts the moment the parser changes, and this repo has just spent a release
* cycle on documentation that confidently asserted the opposite of the code —
* `.env.example` said the tool mode defaulted to `full` when #667 had made it
* `compact`, and a user reading it configured the reverse of what they wanted.
* Deriving makes that class of drift impossible rather than unlikely.
*
* Deliberately carries NO tool COUNT. RFC #726 consolidates the surface to ~30
* tools, so any number written here expires; the text describes the shapes
* instead ("three meta-tools" is a property of the facade, not a census).
*
* TRANSLATED, PROSE ONLY. The left-hand column is flags and subcommands — what the user has
* to type, identical in every language — so only the descriptions go through `tr()`, and
* `helpRow` above owns the alignment that used to live in the string literals.
*/
export function renderCliHelp() {
const d = parseCliArgs([], {});
// The whole parenthetical is one key, not the word "default" glued to a value. A lone
// adjective is the classic untranslatable unit — several of our languages inflect it by
// what follows — and the value itself is a machine token that must not be touched.
const def = (v) => tr("cli.help_default", "(default: {value})", { value: String(v) });
return [
"",
`comfyui-mcp — ${tr("cli.help_tagline", "drive ComfyUI from an AI agent")}`,
"",
tr("cli.help_section_usage", "USAGE"),
helpRow("comfyui-mcp [options]", tr("cli.help_usage_server", "start the MCP server")),
helpRow("comfyui-mcp connect [<comfyui-url>]", tr("cli.help_usage_connect", "run the panel orchestrator against a (possibly remote) ComfyUI")),
helpRow("comfyui-mcp setup <hermes|openclaw|copilot> [--compact|--full] [--comfyui-url <url>] [--dry-run]", ""),
helpRow("", tr("cli.help_usage_setup", "write the comfyui entry into that harness's config, then exit")),
helpRow("comfyui-mcp launcher <install|status|uninstall>", tr("cli.help_usage_launcher", "manage the panel autostart companion")),
"",
tr("cli.help_section_tool_surface", "TOOL SURFACE"),
helpRow("--compact", `${tr("cli.help_compact", "register only the three meta-tools")} ${def(d.toolMode === "compact")}`),
// Not translated: three tool NAMES the user will type or read in a client.
helpRow("", "(list_tools / describe_tool / call_tool)"),
helpRow("--full", tr("cli.help_full", "register the full direct tool surface")),
helpRow("--tool-mode <compact|full>", tr("cli.help_same_as_value", "same, as a value")),
helpRow("", `env: COMFYUI_MCP_TOOL_MODE ${def(d.toolMode)}`),
"",
tr("cli.help_section_transport", "TRANSPORT"),
helpRow("--stdio", `${tr("cli.help_stdio", "stdio transport")} ${def(d.transport === "stdio")}`),
helpRow("--http", tr("cli.help_http", "streamable-HTTP transport")),
helpRow("--transport <stdio|http>", tr("cli.help_same_as_value", "same, as a value"), "env: MCP_TRANSPORT"),
helpRow("--host <host>", tr("cli.help_host", "HTTP bind host"), `env: MCP_HOST ${def(d.host)}`),
helpRow("--port <port>", tr("cli.help_port", "HTTP bind port"), `env: MCP_PORT ${def(d.port)}`),
helpRow("--token <token>", tr("cli.help_token", "require this shared secret on /mcp"), "env: COMFYUI_MCP_HTTP_TOKEN"),
helpRow("--tunnel", tr("cli.help_tunnel", "open a cloudflared quick tunnel (implies --http)"), "env: MCP_TUNNEL"),
helpRow("--allow-unauthenticated-non-loopback", tr("cli.help_allow_unauth", "allow an OPEN /mcp on a non-loopback host")),
"",
tr("cli.help_section_panel", "PANEL / COMFYUI"),
helpRow("--panel-orchestrator", tr("cli.help_panel_orchestrator", "run the background orchestrator that drives the panel")),
helpRow("--comfyui-url <url>", tr("cli.help_comfyui_url", "target a specific (incl. remote) ComfyUI"), "env: COMFYUI_URL"),
helpRow("--insecure-bridge", tr("cli.help_insecure_bridge", "allow an unauthenticated panel bridge")),
"",
// `--lang` is parsed straight out of argv by processLocale(), not by parseCliArgs, so it
// is the one flag that would otherwise be undiscoverable from the tool it controls.
//
// ACTIVE, not "default", and that word is load-bearing. Every other value on this screen
// comes from `parseCliArgs([], {})` — a built-in default, deliberately blind to the
// environment. The language is the opposite: it is resolved FROM the environment through
// four variables and a fallback chain, so the only answer worth printing is the one the
// user is actually getting. Calling that a "default" would be false the moment they pass
// the flag this very row documents.
helpRow("--lang <code>", tr("cli.help_lang", "language for this CLI's own output"), `env: COMFYUI_MCP_LANG ${tr("cli.help_active", "(active: {locale})", { locale: processLocale() })}`),
helpRow("-h, --help", tr("cli.help_help", "show this and exit")),
"",
tr("cli.help_full_reference", "Full reference: {url}", {
url: "https://github.com/artokun/comfyui-mcp#readme",
}),
"",
].join("\n");
}
//# sourceMappingURL=cli.js.map