]` | Stop-then-launch the local ComfyUI (best-effort stop); forwards `extra_args` to the fresh server. Handy for relaunching with different flags — which is why it carries `launch_comfyui`'s **network-exposure confirmation** unchanged (`--listen` on a non-loopback address, or `--enable-cors-header`, asks the USER first). The gate runs **before** the stop, so a declined restart leaves the running server alone rather than killing it and then refusing to bring it back. **When the stop finds nothing recorded and the launch then loses the port**, a ComfyUI is running that comfy-cli did not start — so this asks comfy-cli what it is (`comfy stop --port --dry-run`, which reports the process it *would* stop without stopping it) and, if the engine positively identifies a ComfyUI, offers to recycle it: the USER is shown that process's **pid, command line and port** and asked, and only on a yes is it stopped (`comfy stop --port
`) and the launch retried once. Declining, an engine that will not vouch for the listener, and a comfy-cli too old to have `comfy stop --port` all land on the same port error as before, enriched with whatever identity the dry run did establish. That confirmation necessarily comes *mid*-sequence, which is safe because the stop half was a no-op — nothing was recorded, so declining leaves the running server untouched. On a client that cannot show prompts the kill needs `confirm_kill_untracked=True`, which an agent may pass **only** once the user has actually agreed (the equivalent outside this server is `comfy stop --port
` in a terminal); like every other confirm flag it grants nothing on a client that *can* be prompted. Sessions pointed at a remote ComfyUI (`COMFYUI_URL` / `COMFYUI_HOST`) never reach that path — the lifecycle verbs are local-only, so which machine's port is in question stops being obvious. Both halves run inside one lifecycle slot, so a concurrent `launch_comfyui` / `stop_comfyui` is refused rather than slipping into the gap between them (bounded by the timeouts, ~4 minutes worst case, or ~10 if it is waiting on that confirmation — the prompt is raised without dropping the slot). |
| `update_comfyui(target="comfy", confirm_update_all=False)` | `comfy update ` | Update the local install: `"comfy"` = ComfyUI core, `"all"` = the installed custom node packs, `"cli"` = comfy-cli itself. This is what `server_info`'s `freshness` block points at when it reports a stale install. Slow (a core update re-installs requirements; 30-minute timeout) and the updated code only takes effect after a `restart_comfyui`. **`target="all"` asks the USER first — and only that target.** It `git pull`s and `pip install`s **every** third-party custom node pack into ComfyUI's Python environment, so it runs code those packs' authors have published since you installed them, and it can move a pack (or a shared dependency) to a version other packs and your saved workflows don't work with. comfy-cli does not gate that, so on a client that supports MCP elicitation a prompt naming exactly that is raised and a decline runs nothing; on a client that cannot show prompts the call errors unless `confirm_update_all=True`, which an agent may pass **only** when the user has actually agreed. That prompt is raised even when `confirm_update_all=True` is passed, so a host's "always allow this tool" toggle is not standing authority to run third-party code. `target="comfy"` and `target="cli"` update first-party code from known repositories and are never prompted. Any other `target` is rejected before comfy-cli is invoked (and before anyone is asked), and a second update requested while one is still running is refused rather than run in parallel (concurrent `git`/`pip` against one workspace can leave it half-installed) — that refusal comes before the prompt too, so nobody approves a call that was never going to run. |
| `switch_comfyui_version(version, confirm_switch=False)` | `comfy update comfy --version ` | Move the local ComfyUI install to a **specific** version — `"nightly"`, `"latest"`, or a release like `"0.24.0"` / `"v0.24.0"` — so you can roll **back** to reproduce or rule out a regression (`update_comfyui` only ever moves forward to the latest). **Destructive:** the engine stashes any uncommitted changes in the ComfyUI checkout, moves it to that version, and reinstalls that version's Python dependencies (minutes, not seconds; 15-minute timeout). **The USER is asked to confirm every call** — on a client that supports MCP elicitation a prompt naming exactly that is raised, and a decline cancels with nothing changed; on a client that cannot show prompts the call errors unless `confirm_switch=True`, which an agent may pass **only** when the user has actually agreed. That prompt is raised even when `confirm_switch=True` is passed, so a host's "always allow this tool" toggle is not standing authority over the install. It **refuses while a local ComfyUI is running** (reinstalling under a live process can leave it serving half-replaced code) — checked both before the prompt and again immediately before the switch, since the prompt may sit unanswered for minutes, and fail-closed, so a `comfy env` this server cannot read is refused rather than read as "stopped" — and it does **not** restart anything — the flow is `stop_comfyui` → `switch_comfyui_version` → `launch_comfyui` → `server_info` to confirm what came up. Returns `{switched_to, result, restart_required: true}`. A malformed version is rejected before comfy-cli is invoked; a comfy-cli whose `comfy update` predates `--version` surfaces as an "upgrade comfy-cli" error rather than a raw usage dump; and it shares `update_comfyui`'s one-at-a-time lock. |
| `install_node(names, confirm_install=False)` | `comfy node install --exit-on-fail` | Install custom node packs into the local ComfyUI — the acquisition half of the missing-node story, after `validate_workflow` / `run_workflow` names a node class this install lacks and `node_dependencies(registry_id=…)` pre-checks the pack's requirements. `names` are **registry pack ids** (slugs like `"comfyui-impact-pack"`), not node class names: a git URL, a filesystem path, or `"all"` is refused before comfy-cli is invoked — the URL case deliberately, because the confirmation prompt promises the user a *named pack from the registry*, so nothing else may ride through it. (To update the packs you already have, use `update_comfyui(target="all")`; to install from a URL, run `comfy node install` in a terminal.) **Installing a pack runs third-party code** — a `pip install` of its dependencies into the ComfyUI environment plus the pack's own install script — so **the USER is asked to confirm every call**, and that prompt is raised even when `confirm_install=True`, since a host's "always allow this tool" toggle is not standing authority to execute third-party code and the pack names are frequently a model's guess. On a client that cannot show prompts the call errors unless `confirm_install=True`, which an agent may pass **only** once the user has actually agreed. It does **not** restart anything — new nodes are invisible until ComfyUI restarts, so the flow is `install_node` → `restart_comfyui` → `nodes(action="search")` — and it shares `update_comfyui`'s one-at-a-time lock (same venv, same `pip`). `--exit-on-fail` is always forwarded, because without it comfy-cli reports a failed install as success — but it is not sufficient on its own: ComfyUI-Manager prints a pack's failure *before* consulting the flag, so `comfy node install` can report a pack as failed and still exit 0. The verdict is therefore read out of the engine's own output rather than off the exit status. 30-minute timeout. Returns `{installed, result, restart_required}` — **`installed` lists only the packs the engine did not report as failed, not an echo of `names`** — plus `{failed, error}` when any pack failed, where each `failed` entry carries the engine's own message and a `code` of `pack_not_found` (the id is not in this install's registry channel, so retrying it will not help) or `install_failed`. `restart_required` is `false` when nothing was installed, because there is then nothing for a restart to pick up. |
| `upload_file(paths, overwrite=False)` | `comfy upload --overwrite/--no-overwrite` | Stage source images/masks into the target ComfyUI's `input` dir (unlocks img2img / inpaint). Goes to whichever ComfyUI the server targets — the local install by default, or the remote a configured `COMFYUI_URL`/`COMFYUI_HOST` names, the same one `run_workflow` submits to ([Driving a remote ComfyUI](#driving-a-remote-comfyui)); remote upload needs comfy-cli ≥ 1.14.0, and an older one raises with the upgrade step instead of staging locally where the remote run cannot see the files. Entries must already exist on **this** filesystem (they are read here and sent to the target) and **should be absolute** — comfy-cli runs with the ComfyUI workspace as its working directory, so a relative path resolves against the workspace, not the agent's cwd. **For an image the user attached in chat:** an MCP server never receives attachment *bytes* (the protocol has no client-to-server path for them), but several clients save the attachment and put its absolute path in the agent's context — Claude Code injects an `[Image: source: ]` line — and that path is an ordinary local file you can pass straight to `paths`. If your client gives no path, ask the user to save the file and supply it; that is the portable flow. |
| `download_model(url, relative_path=None, filename=None, wait=True, timeout_seconds=110.0)` | `comfy model download --url [--relative-path ] [--filename ] --background` | Download a model file by direct URL (HuggingFace / CivitAI) into the local models dir; download-by-URL only, not a hub search. Local-only and **enforced**: `comfy model download` has no `--host`/`--port`, so with a remote configured (`COMFYUI_URL`/`COMFYUI_HOST`) this refuses instead of writing the checkpoint to a disk the remote cannot see — install the model on the remote host itself, or set `COMFY_MCP_REMOTE_SHARED_MODELS=1` if this machine's models dir *is* the remote's (shared NFS / tailnet mount). See [Driving a remote ComfyUI](#driving-a-remote-comfyui). The transfer is **submitted** to comfy-cli's background worker and returns a `download_id`, so a multi-GB checkpoint no longer holds the MCP request open past the client's deadline: `wait=True` (default) polls that id for you within a bounded budget and returns `{"timed_out": True, "download_id": …}` — not an error — if the transfer is still running, while a `failed` / `cancelled` download raises with comfy-cli's own error. On that path `timeout_seconds` is the **end-to-end** budget for the whole call, submit included, so the submit and the poll cannot add up past the client deadline the 110s default is chosen to sit under. `wait=False` returns the submit payload immediately and keeps the submit's own fixed budget. Every payload from that background path keys the handle `download_id`, matching the argument name every download tool takes, so an id read out of one result goes straight back into the next call — the legacy foreground fallback below is the exception, since no id is ever minted on it. The file is written straight to its final path as it transfers, so a filesystem / `search_models` check mid-flight sees a present-but-incomplete file — `download(action="status")` is the source of truth. `relative_path` resolves from the workspace root and must be the models dir or a subfolder of it — `models`, `models/loras` (a bare `loras` is rejected, not assumed); sibling dirs like `custom_nodes/…`, `input`, `output` are refused. Use `/` as the separator on every host, Windows included. Against a comfy-cli too old to know `--background` (anything below 1.14.0, which only reaches here past the fail-open version guard) it falls back to the previous foreground download — which has no id to detach or poll, so it blocks even on `wait=False`, and every payload it returns is marked `background_unsupported: true` to say so. On that fallback `wait=True` is bounded by what is left of your `timeout_seconds` (capped at 1800s) rather than by a silent half hour: when the bound expires the transfer is killed and the error names where an incomplete file may remain, since there is no `download_id` to check it with. Cancelling the tool call kills the transfer the same way instead of orphaning it. |
| `download(action="status", download_id="", timeout_seconds=None)` | `comfy model download-status/download-cancel ` | One grouped tool over the three former `download_status`/`wait_for_download`/`cancel_download` tools — pick a behavior with `action`. Does **not** start a transfer — that's `download_model`, whose `download_id` this tool consumes. `"status"` (default) returns `status`, `completed_bytes` / `total_bytes` / `percent`, `elapsed_seconds`, `dest`, and `error` — the only proof a model is complete and loadable. `"wait"` polls (bounded, default 25.0s, ceiling 3600s) until a download reaches a terminal state (completed / failed / cancelled), returning a `{"timed_out": True, …}` payload on expiry — chain several rather than one long call, the `job(action="wait")` shape, for transfers. `"cancel"` stops a running download and removes its partial file. `download_id` is required for every action; `timeout_seconds` only for `"wait"` — passing it elsewhere is rejected rather than silently ignored. Every payload keys the handle `download_id` on the way back out too, including the status nested inside a `"wait"` timeout, so a handle read out of one result passes straight into the next call without renaming; comfy-cli spells the same field `id`, and that spelling is kept alongside rather than replaced. On a comfy-cli without the verb, returns `{"error": …, "unsupported": true}` instead of a raw usage dump — that shape carries no handle at all, since a CLI that old can never have minted one. |
Node introspection (`nodes`, all eight actions) and `search_models`
read the **user's live install** (custom nodes included), not a static catalog — that's the
local differentiator from the cloud MCP's equivalents. The graph-wiring actions (`"upstream"` /
`"downstream"` / `"path"`) are what an agent authoring a workflow uses to find compatible nodes.
`workflow_deps` is the one node tool that does NOT read the live install: it resolves a workflow's classes against ComfyUI-Manager's node→pack map, which is what lets it name a pack that is not installed — precisely the question the live-catalog tool cannot answer.
`node_dependencies` reads that same live install from the other side — the **packs** on disk and
the venv they installed into, rather than the node classes ComfyUI loaded from them — which is how
an agent tells "this pack's nodes are missing from `object_info`" apart from "this pack's Python
dependencies never installed".
## Troubleshooting
### macOS: `PermissionError: [Errno 1] Operation not permitted` / `Fatal Python error`
**Symptom.** Setup fails with a raw Python startup crash naming a file under `~/Documents`,
`~/Desktop` or `~/Downloads` — most often the ComfyUI venv's `pyvenv.cfg`:
```text
Fatal Python error: init_import_site: Failed to import the site module
PermissionError: [Errno 1] Operation not permitted: '/Users/you/Documents/ComfyUI/venv/pyvenv.cfg'
```
**Cause.** macOS protects those three folders with TCC (Transparency, Consent & Control). An app
without **Full Disk Access** cannot read them — and neither can the processes it spawns. So when
your ComfyUI install (and its `venv`) lives under one of them, the `comfy` binary your MCP client
launches dies before it executes a single line. Nothing is wrong with ComfyUI, comfy-cli, or this
server: it is a macOS privacy setting.
**Fix — either one works:**
1. **Grant your MCP client Full Disk Access.** System Settings → Privacy & Security → Full Disk
Access → add the app (Claude Desktop, Cursor, or the terminal you launch the client from), then
**quit and reopen it** so the new permission takes effect.
2. **Or move the ComfyUI folder somewhere unprotected** — e.g. `~/ComfyUI` — and re-point comfy-cli
at it with `comfy set-default `. Update `COMFY_BIN` in your client config too if it names
a path inside the old location.
Where it can, the server says this for you: a tool call blocked this way returns the guidance above
instead of the raw traceback. The one case it cannot catch is **its own** interpreter startup (this
server installed under a protected folder) — Python dies before any of its code runs, so that one
surfaces as the raw traceback in your client's MCP logs. Same fix.
## Failure log (opt-in)
When you're diagnosing a flaky setup, an MCP client's transcript is a poor record: it scrolls, it
truncates, and the interesting failures (a missing `comfy` binary, a crash before any JSON, a
timeout) are exactly the ones that leave the least behind. Set **`COMFY_MCP_DEBUG_LOG`** and
the server appends one JSON object per comfy-cli **failure** to a local file you can `jq`, grep, or
zip up and attach to a bug report.
| Value | Behavior |
| --- | --- |
| unset, empty, or `0` | **Off (the default).** Nothing is created and no log file is opened. |
| `1` | On, at the default path for your OS (below). |
| anything else | On, and the value is used as the log file path (parent directories are created). |
Default paths — the same per-OS local-state convention comfy-cli itself uses:
| OS | Path |
| --- | --- |
| macOS | `~/Library/Application Support/comfy-mcp/failures.jsonl` |
| Windows | `~/AppData/Local/comfy-mcp/failures.jsonl` |
| Linux / other | `~/.config/comfy-mcp/failures.jsonl` |
Each line records the failure `kind` (`error_envelope`, `no_json`, `timeout`, `binary_missing`,
`schema_mismatch`), a UTC `ts`, the comfy-cli `args`, its `exit_code` and the envelope's
`error_code`, the message you saw in your client, and up to 4,000 characters of `stdout_tail` /
`stderr_tail` — deliberately more output than an error message can carry:
```console
$ COMFY_MCP_DEBUG_LOG=1 … # in your MCP client config's env block
$ jq -r 'select(.kind == "timeout") | .ts + " " + (.args | join(" "))' \
~/Library/Application\ Support/comfy-mcp/failures.jsonl
```
The file rotates itself: 1 MiB per file with two older generations kept (`failures.jsonl.1`,
`failures.jsonl.2`), so it stops growing at roughly 3 MiB no matter how long you leave it on.
Successful calls are never recorded, and nothing is ever transmitted anywhere — the log is local,
full stop.
> **Privacy — review before sharing.** The log contains local file paths and comfy-cli's own
> command output, which can include the workflow or prompt text comfy-cli echoed back. Credentials
> in a URL are masked (`user:pass@` userinfo, and the whole query string, are stripped) wherever
> the URL appears — in `args`, in `message`, and in the `stdout_tail` / `stderr_tail` captures —
> but read a file over before you attach it to an issue. The log directory is created `0700` and
> its files `0600`, so on a shared machine they are readable only by you.
## Smoke test
Turn the manual validation ritual into one command. The e2e smoke test drives the
real tools end-to-end (no mocks): `server_info` → `run_workflow` on a checkpoint-free
`EmptyImage` → `SaveImage` graph → `fetch_outputs`, and asserts a valid PNG lands in
a temp out_dir.
```bash
./scripts/smoke.sh # or: python -m pytest tests/e2e -m e2e
```
It needs a running local ComfyUI (`COMFYUI_URL`, default `http://127.0.0.1:8188`)
**and** the `comfy` binary on `PATH` (or `COMFY_BIN`). Without both it **skips**
rather than fails. The e2e tests are deselected by default from plain `pytest`
runs, so it's safe to run anywhere — and the `pytest` gate stays green on CI
runners that have neither.
## Contributing
Contributions are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for dev
setup (`pip install -e '.[dev]'`, `pytest`, `ruff`) and the thin-wrapper
architecture rule, and [`AGENTS.md`](AGENTS.md) for the full guidelines. This
project follows a [Code of Conduct](CODE_OF_CONDUCT.md). To report a
vulnerability, see [`SECURITY.md`](SECURITY.md).
## License
Comfy MCP is dual-licensed (see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE)):
- **[GNU Affero General Public License v3.0 or later](LICENSE)** — free for use
under the AGPL's terms, including the network-use source-disclosure
obligation in section 13.
- **Commercial license** — for use in proprietary products or hosted services
without AGPL obligations. Contact
**[licensing@comfy.org](mailto:licensing@comfy.org)**.
It wraps the GPL-3.0 [`comfy-cli`](https://github.com/Comfy-Org/comfy-cli) by
shelling out to the `comfy` binary as a separate process — no GPL code is
imported or linked. `comfy-cli` remains GPL-3.0-licensed and is distributed
separately; how its copyleft applies depends on how the programs interact.
© Comfy Org.
## Trademarks
"Comfy," "ComfyUI," and the Comfy Org name and logos — including the mark in
[`assets/logo.svg`](assets/logo.svg) — are trademarks of Comfy Org. The AGPL is
a copyright license and grants **no** rights to use those names or logos; the
commercial license grants none either unless it says so in writing. Forks and
derivative works are welcome under the license, but must not be named or branded
in a way that suggests they are official Comfy Org software or carry Comfy Org's
endorsement.
Accurate, descriptive references — tutorials, reviews, integrations — are
welcome. See the [brand guidelines](https://www.comfy.org/brand) for the full
rules and how to request permission beyond them.