Metadata-Version: 2.5
Name: curu-comfy-mcp
Version: 1.0.0
Summary: An MCP server that lets agents use ComfyUI backends through the ComfyUI API.
Project-URL: Repository, https://github.com/darth-veitcher/curu-comfy-mcp
Project-URL: Documentation, https://github.com/darth-veitcher/curu-comfy-mcp#readme
Project-URL: Changelog, https://github.com/darth-veitcher/curu-comfy-mcp/blob/main/CHANGELOG.md
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.12
Requires-Dist: anyio>=4.9
Requires-Dist: httpx2>=2.10
Requires-Dist: mcp<2.4,>=2.3
Requires-Dist: pydantic>=2.12
Requires-Dist: pyjwt[crypto]>=2.10
Requires-Dist: websockets>=15
Description-Content-Type: text/markdown

# curu-comfy-mcp

## What is this

curu-comfy-mcp is an MCP server that lets an AI agent use ComfyUI. The agent sends a workflow, watches the job and gets the output files through MCP tools. Other tools show the models and the node types of the backend, so that the agent can write a workflow that the backend can run. The agent writes no HTTP or WebSocket code. The MCP client starts curu-comfy-mcp on its own computer, or connects to it over HTTP. curu-comfy-mcp is for a person who runs ComfyUI and wants agents, for example Claude Code, to use it. A Python program, for example curu, can also use its ComfyUI client as a library, with no MCP.

## What you can do

| Task | Tool or command | Section |
|---|---|---|
| Connect an MCP client to one ComfyUI | `claude mcp add comfy -- curu-comfy-mcp` | [Connect an MCP client](#connect-an-mcp-client) |
| Let a remote client use curu-comfy-mcp | `curu-comfy-mcp --transport http` | [Serve the tools over HTTP](#serve-the-tools-over-http) |
| Let clients sign in with OIDC | the `[oidc]` table | [Sign in to curu-comfy-mcp with OIDC](#sign-in-to-curu-comfy-mcp-with-oidc) |
| Use a ComfyUI behind `comfyui_curu_auth` | `CURU_COMFY_MCP_BACKEND_TOKEN`, or the `oidc` table of a backend | [Reach a backend behind comfyui_curu_auth](#reach-a-backend-behind-comfyui_curu_auth) |
| Reach a backend that accepts OIDC tokens | the `oidc` table of a backend | [Reach a backend with its own OIDC client](#reach-a-backend-with-its-own-oidc-client) |
| Use more than one ComfyUI | `list_backends`, the `backend` input | [Use more than one backend](#use-more-than-one-backend) |
| Find the models and the node types of the backend | `list_models`, `list_node_types`, `get_node_type` | [Find the models and the node types of the backend](#find-the-models-and-the-node-types-of-the-backend) |
| See the queue and the recent jobs | `get_queue`, `list_recent_jobs` | [See the queue and the recent jobs](#see-the-queue-and-the-recent-jobs) |
| Read the VRAM and the versions of a backend | `get_system_stats` | [Read and release the VRAM of a backend](#read-and-release-the-vram-of-a-backend) |
| Release the VRAM of a backend from Python | `await client.free(unload_models=True, free_memory=True)` | [Read and release the VRAM of a backend](#read-and-release-the-vram-of-a-backend) |
| Upload an input image for a `LoadImage` node | `upload_image` | [Upload an input image](#upload-an-input-image) |
| Send a workflow | `submit_workflow` | [Send a workflow](#send-a-workflow) |
| Read or wait for the state of a job | `get_job`, `wait_for_job` | [Watch a job](#watch-a-job) |
| Stop a queued or a running job | `cancel_job` | [Cancel a job](#cancel-a-job) |
| Get an output file | `get_output_file` | [Get the output files](#get-the-output-files) |
| Find the cause of a failure | the `code` of the error | [Handle errors](#handle-errors) |
| Run a job from Python, with no MCP | `from curu_comfy_mcp import ComfyUIClient` | [Use curu-comfy-mcp as a library](#use-curu-comfy-mcp-as-a-library) |
| Change the backends, the file size limits or the inbound token | the configuration file, `CURU_COMFY_MCP_*` variables, `--config`, `--backend` | [Configure curu-comfy-mcp](#configure-curu-comfy-mcp) |

## Install

You need Python 3.12 or later and [uv](https://docs.astral.sh/uv/). Install the `curu-comfy-mcp` command from PyPI:

```bash
uv tool install curu-comfy-mcp
```

To use the client in your own Python project, add curu-comfy-mcp to the project:

```bash
uv add curu-comfy-mcp
```

The PyPI package `comfy-mcp` is a different project of Comfy Org. Do not install it.

To install the newest code that has no release yet, use `git+https://github.com/darth-veitcher/curu-comfy-mcp` in place of `curu-comfy-mcp`.

You also need a ComfyUI that curu-comfy-mcp can reach over HTTP. ComfyUI needs no custom node.

## Quickstart

1. Start ComfyUI. By default it listens on `http://127.0.0.1:8188`.
2. Add curu-comfy-mcp to Claude Code:

   ```bash
   claude mcp add comfy -e CURU_COMFY_MCP_BACKEND_URL=http://127.0.0.1:8188 -- curu-comfy-mcp
   ```

3. Start Claude Code, and give it this request:

   ```text
   Use the comfy tools. Send this workflow, wait for it, and show me the image:
   {"1": {"class_type": "EmptyImage", "inputs": {"width": 64, "height": 64, "batch_size": 1, "color": 16711680}},
    "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0], "filename_prefix": "quickstart"}}}
   ```

The agent calls `submit_workflow`, then `wait_for_job`, then `get_output_file`. You see a red square of 64 by 64 pixels. This workflow uses only the core nodes of ComfyUI, so it needs no model.

## Connect an MCP client

By default, curu-comfy-mcp is a stdio MCP server: the MCP client starts the `curu-comfy-mcp` command and talks to it on stdin and stdout. Give the URL of the backend in the environment of the command.

For Claude Code:

```bash
claude mcp add comfy -e CURU_COMFY_MCP_BACKEND_URL=http://<host>:8188 -- curu-comfy-mcp
```

Replace `<host>` with the host name of your ComfyUI.

For a client with a JSON configuration, for example Claude Desktop:

```json
{
  "mcpServers": {
    "comfy": {
      "command": "curu-comfy-mcp",
      "env": {"CURU_COMFY_MCP_BACKEND_URL": "http://<host>:8188"}
    }
  }
}
```

The client then lists the job tools `submit_workflow`, `get_job`, `wait_for_job` and `get_output_file`, and the read tools `list_backends`, `list_models`, `list_node_types`, `get_node_type`, `get_queue` and `list_recent_jobs`.

## Serve the tools over HTTP

Serve curu-comfy-mcp over HTTP when the agent runs on a different computer, or when a program such as curu connects to it. curu-comfy-mcp then uses the MCP streamable HTTP transport, at the path `/mcp`.

1. Make an inbound token. The token must have 32 characters or more.

   ```bash
   export CURU_COMFY_MCP_HTTP_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
   ```

2. Start curu-comfy-mcp on HTTP:

   ```bash
   curu-comfy-mcp --transport http --host 0.0.0.0 --port 8000
   ```

3. Connect the client with the token. For Claude Code:

   ```bash
   claude mcp add --transport http comfy http://<host>:8000/mcp --header "Authorization: Bearer $CURU_COMFY_MCP_HTTP_TOKEN"
   ```

The client lists the same tools as on stdio, with the same results and progress notifications.

The inbound token protects curu-comfy-mcp itself. Each request must send it as `Authorization: Bearer <token>`. A request without the correct token gets HTTP 401 with a `WWW-Authenticate: Bearer` header, and no tool runs. curu-comfy-mcp does not start on HTTP without a token or an OIDC issuer, so the endpoint cannot be open by mistake.

| Option | Default | Meaning |
|---|---|---|
| `--transport` | `stdio` | `stdio` when the MCP client starts curu-comfy-mcp. `http` for remote clients. |
| `--host` | `127.0.0.1` | The address that the HTTP server listens on. `127.0.0.1` accepts only clients on the same computer. `0.0.0.0` accepts clients on each network interface. |
| `--port` | `8000` | The port of the HTTP server, from 1 to 65535. |
| `--config` | none | Read this configuration file. The file must exist (see [Configure curu-comfy-mcp](#configure-curu-comfy-mcp)). |
| `--backend` | none | Use this backend for a tool call that names no backend, in this process only. |

curu-comfy-mcp has no TLS, so the token and the results go over the network as plain text. Put a reverse proxy with TLS in front of curu-comfy-mcp on a network that you do not trust.

## Sign in to curu-comfy-mcp with OIDC

Let each HTTP client sign in with an access token from your identity provider, for example Authentik or rauthy. curu-comfy-mcp checks each token itself, as an OAuth 2.1 resource server. The static inbound token of [Serve the tools over HTTP](#serve-the-tools-over-http) can stay on. A request then passes with either one.

1. Register curu-comfy-mcp at your identity provider. Note the issuer, the `aud` value and the `typ` of its tokens. [docs/oidc-providers.md](docs/oidc-providers.md) gives them for each provider.
2. Write the `[oidc]` table in the configuration file:

   ```toml
   [oidc]
   issuer = "https://auth.example.com/application/o/comfy-mcp/"
   resource = "https://mcp.example.com/mcp"
   audience = "comfy-mcp"
   token_types = ["at+jwt", "JWT"]
   required_scopes = ["profile"]
   ```

   The values `comfy-mcp` in the issuer and in `audience` are an example of an Authentik client (see [Use Authentik](#use-authentik)). Use the values of your own client.

3. Start curu-comfy-mcp on HTTP. With an issuer, it needs no static token:

   ```bash
   curu-comfy-mcp --transport http --host 0.0.0.0 --port 8000
   ```

4. Let the MCP client get a token from the identity provider. The client sends it as `Authorization: Bearer <access token>`.

| Key | Meaning |
|---|---|
| `issuer` | The issuer URL of the identity provider, exactly as its discovery document gives it. Keep a `/` at the end if the document has one. It must be `https://`, or `http://` on `127.0.0.1`, `localhost` or `::1`. |
| `resource` | The URL at which clients reach `/mcp`. Behind a reverse proxy, give the public URL of the proxy. curu-comfy-mcp publishes it in its metadata. |
| `audience` | The value that the `aud` claim of each token must hold. The default is `resource`. Authentik puts the client id of its provider in `aud`. |
| `token_types` | The values of `typ` in the token header that curu-comfy-mcp accepts. The default is `["at+jwt"]`, the type of RFC 9068. Authentik writes `JWT`. |
| `required_scopes` | The scopes that each token must have. The default is no scope. A token without one of them gets HTTP 403. |

curu-memory is a different MCP server of the same owner. `issuer`, `resource`, `audience` and `token_types` have the names and meanings of its `CURU_MEMORY_OIDC_*` settings. One identity provider can then serve both servers in the same way.

curu-comfy-mcp reads the discovery document of the issuer at the first token, and then the keys at its `jwks_uri`. It keeps the keys in memory. When a token names an unknown key, curu-comfy-mcp reads the keys again, at most once in 60 seconds. Because of this, a key rotation at the identity provider does not stop curu-comfy-mcp.

curu-comfy-mcp gives HTTP 401 for a token with a wrong issuer, a wrong audience, a bad signature or a past expiry. It also refuses these tokens:

- A token with an algorithm other than RS256 and ES256, for example HS256.
- A token with a `typ` that is not in `token_types`.
- A token of the type `JWT`, or with no type, that has no client id, or that has no scope. An ID token has the same `aud` as an access token, but it has no scope, so curu-comfy-mcp refuses it.
- A token that lives more than 1 hour, from `iat` to `exp`.

A valid token without a required scope gets HTTP 403 with `insufficient_scope`. A refused request reaches no tool. Each 401 and 403 has a `WWW-Authenticate` header with `resource_metadata`, the URL of the metadata of curu-comfy-mcp. curu-comfy-mcp allows 60 seconds of clock difference for `nbf` and `iat`, but none for `exp`.

An MCP client reads that metadata to find the identity provider. It is at `/.well-known/oauth-protected-resource/mcp`, with no token:

```json
{"resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/application/o/comfy-mcp/"], "scopes_supported": ["profile"], "bearer_methods_supported": ["header"]}
```

The static token carries each required scope, because it is the token of the operator. On stdio, curu-comfy-mcp checks no token. For each job, the log of curu-comfy-mcp names the subject of the token. With no subject, it names the client id.

### Use Authentik

A blueprint is a YAML file from which Authentik makes its objects. If you manage Authentik with [ansible-ubuntu-server](https://github.com/darth-veitcher/ansible-ubuntu-server), its `authentik` role makes this blueprint from `roles/authentik/templates/comfy-mcp-oidc.yaml.j2`. Set `comfy_mcp_oidc_enabled: true` to turn it on. Use that role, not a copy of this example.

The example shows what the blueprint makes. It has the same shape as the Authentik client of curu-memory. The name `comfy-mcp` in it is the slug, the client id and the audience of an example client. It is not the name of the command, and you can use a different name:

```yaml
version: 1
metadata:
  name: comfy-mcp OIDC
entries:
  - id: group-comfy-mcp
    model: authentik_core.group
    identifiers:
      name: comfy-mcp-users
    attrs:
      is_superuser: false
  - id: provider-comfy-mcp
    model: authentik_providers_oauth2.oauth2provider
    identifiers:
      name: comfy-mcp
    attrs:
      client_type: public
      client_id: comfy-mcp
      redirect_uris:
        - matching_mode: regex
          url: "^http://localhost:[0-9]+/callback$"
        - matching_mode: regex
          url: "^http://127\\.0\\.0\\.1:[0-9]+/callback$"
      authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
      invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
      signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
      access_token_validity: minutes=10
      property_mappings:
        - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
        - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
  - id: app-comfy-mcp
    model: authentik_core.application
    identifiers:
      slug: comfy-mcp
    attrs:
      name: comfy-mcp
      provider: !KeyOf provider-comfy-mcp
  - model: authentik_policies.policybinding
    identifiers:
      target: !KeyOf app-comfy-mcp
      group: !KeyOf group-comfy-mcp
      order: 0
    attrs:
      enabled: true
```

- The group `comfy-mcp-users` and the policy binding give tokens only to the members of the group. This applies to persons and to agents.
- The provider is a public client, so an MCP client on a laptop signs in with a browser and PKCE, with no client secret. The two redirect URIs accept the callback `http://localhost:<port>/callback` or `http://127.0.0.1:<port>/callback`, for example of Claude Code.
- `signing_key` makes Authentik sign with RS256 and publish its keys. Without it, Authentik signs with HS256 and the client secret, which curu-comfy-mcp refuses.
- `access_token_validity` makes each token live 10 minutes. curu-comfy-mcp refuses a token that lives more than 1 hour.
- The issuer is `https://<authentik host>/application/o/comfy-mcp/`, from the slug of the application.
- `aud` and `azp` are `comfy-mcp`, the `client_id` of the provider. Because of this, the `[oidc]` table above sets `audience = "comfy-mcp"`.
- Authentik writes `typ: JWT`. Because of this, the table sets `token_types = ["at+jwt", "JWT"]`.

An agent is an Authentik service account with an app password. A service account is an Authentik user for a program, and an app password is a secret of that user. Add the service account to `comfy-mcp-users`. The agent gets a token with the client credentials grant: it sends `client_id`, `username` and `password` (the app password).

Authentik has no dynamic client registration, so each MCP client uses the client id `comfy-mcp`.

### Use rauthy for development

[docs/oidc-providers.md](docs/oidc-providers.md#use-rauthy) gives the commands that start rauthy with a test client on your computer. The tests of curu-comfy-mcp use the same setup.

## Reach a backend behind comfyui_curu_auth

Give curu-comfy-mcp the backend token when ComfyUI runs [comfyui_curu_auth](https://github.com/darth-veitcher/comfyui_curu_auth). Without the backend token, each tool gives the error `backend_auth_failed`.

`comfyui_curu_auth` 2.1.0 and later can also accept an OIDC access token. When the owner of the ComfyUI turns this on, give the backend an `oidc` table in place of the backend token. See [Reach comfyui_curu_auth with an OIDC client](#reach-comfyui_curu_auth-with-an-oidc-client).

1. Copy the credential that `comfyui_curu_auth` prints on the ComfyUI console when ComfyUI starts. This credential is the backend token.
2. Set it in the environment of curu-comfy-mcp. For Claude Code:

   ```bash
   claude mcp add comfy -e CURU_COMFY_MCP_BACKEND_URL=http://<host>:8188 -e CURU_COMFY_MCP_BACKEND_TOKEN='<backend-token>' -- curu-comfy-mcp
   ```

curu-comfy-mcp then sends `Authorization: Bearer <backend-token>` on each call to the backend and on the `/ws` connection. curu-comfy-mcp removes the white space at the two ends of the value, so a new-line character from the copy causes no error.

The backend token and the inbound token of [Serve the tools over HTTP](#serve-the-tools-over-http) are two different secrets. The backend token lets curu-comfy-mcp into ComfyUI. The inbound token lets MCP clients into curu-comfy-mcp.

`comfyui_curu_auth` blocks curu-comfy-mcp after each wrong backend token. The block is 1 second after the first failure, and two times longer after each next failure, to a maximum of 300 seconds. During a block, each tool gives `backend_rate_limited` with the seconds to wait. To prevent a longer block, curu-comfy-mcp waits 30 seconds after `comfyui_curu_auth` refuses the backend token on `/ws` (HTTP 401 or 403). During a block, it waits the time that `comfyui_curu_auth` gives.

## Use more than one backend

Write each ComfyUI in the configuration file, with a name. An agent then gives the name in the `backend` input of a tool. A tool call with no name uses the default backend.

1. Write the file `~/.config/curu/curu-comfy-mcp/config.toml`. For the other locations, see [Configure curu-comfy-mcp](#configure-curu-comfy-mcp):

   ```toml
   default_backend = "local"

   [backends.local]
   url = "http://127.0.0.1:8188"

   [backends.orange]
   url = "https://orange.example:8443"
   token_env = "ORANGE_COMFY_TOKEN"
   ```

2. Give the token of each backend behind `comfyui_curu_auth` in the variable that its `token_env` names. For Claude Code:

   ```bash
   claude mcp add comfy -e ORANGE_COMFY_TOKEN='<backend-token>' -- curu-comfy-mcp
   ```

- A backend name has 1 to 64 letters, digits, `-` or `_`.
- With two or more backends, name the default backend in `default_backend`, `CURU_COMFY_MCP_DEFAULT_BACKEND` or `--backend`. Else curu-comfy-mcp stops at start.
- If the variable that `token_env` names is not set, curu-comfy-mcp stops at start and names the variable.
- Put no secret in the file. curu-comfy-mcp stops at start when the file has a `token` key, or a URL with a password.
- curu-comfy-mcp sends each token only to its own backend.

`list_backends` gives each backend, the default backend and whether each one answers. It asks each backend at the same time, and waits 5 seconds or less for each:

```json
{"backends": [{"name": "local", "url": "http://127.0.0.1:8188", "default": true, "online": true}, {"name": "orange", "url": "https://orange.example:8443", "default": false, "online": false, "reason": "backend_auth_failed"}]}
```

`reason` is the error code of a backend that is not online. The URL never shows a user name or a password.

Each tool that uses a backend has the input `backend`. For example, send a workflow to `orange`:

```json
{"workflow": {"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "cat"}}}, "backend": "orange"}
```

Each job result, the queue and the list of recent jobs give `backend`, the name of their backend:

```json
{"job_id": "8f1c2e7a-0d5b-4e43-9a51-2c7e6f0b9d14", "queue_number": 3, "backend": "orange"}
```

`get_job`, `wait_for_job` and `get_output_file` find the backend of a job when you give no name. curu-comfy-mcp asks the backend that it sent the job to. After a restart of curu-comfy-mcp, it asks the default backend first, then the other backends at the same time. curu-comfy-mcp stores no file for this, because the backends hold each job.

## Reach a backend with its own OIDC client

Give a backend an OIDC client when the backend accepts an access token from an identity provider. curu-comfy-mcp then gets its own access token for that backend, with the OAuth 2.0 client credentials grant. It never sends the token of an MCP client to a backend, because the MCP specification forbids that.

1. Register curu-comfy-mcp at the identity provider of the backend, as a confidential client with the client credentials grant. Note its client id and its client secret.
2. Write the `oidc` table of the backend in the configuration file:

   ```toml
   [backends.orange]
   url = "https://orange.example:8443"

   [backends.orange.oidc]
   issuer = "https://auth.example.com/auth/v1/"
   client_id = "comfy-mcp-orange"
   client_secret_env = "ORANGE_OIDC_SECRET"
   resource = "https://orange.example:8443"
   ```

3. Set the client secret in the variable that `client_secret_env` names. For Claude Code:

   ```bash
   claude mcp add comfy -e ORANGE_OIDC_SECRET='<client-secret>' -- curu-comfy-mcp
   ```

| Key | Meaning |
|---|---|
| `issuer` | The issuer of the identity provider. curu-comfy-mcp reads the token endpoint from its discovery document. It must be `https://`, or `http://` on a loopback host. |
| `token_endpoint` | The token endpoint. Give it in place of `issuer` when the provider has no discovery document. |
| `client_id` | The client id of curu-comfy-mcp at the provider. |
| `client_secret_env` | The variable that holds the client secret. The secret never goes into the file. |
| `username` | The user name of an Authentik service account. With it, the secret is the app password of that account. |
| `scope` | The `scope` of the token request, with spaces between the scopes. |
| `audience` | The `audience` of the token request. Auth0 needs it. |
| `resource` | The `resource` of the token request (RFC 8707). rauthy puts it in `aud` when the client allows it. |
| `auth_method` | `client_secret_post` (the default) sends the secret in the form. `client_secret_basic` sends it in an HTTP Basic header. |

curu-comfy-mcp asks for a token at the first call to the backend, and keeps it in memory. It gets a new token 60 seconds before the old one expires, or at half the lifetime of a token of less than 2 minutes. Calls at the same time share one token request. curu-comfy-mcp sends the token as `Authorization: Bearer <token>` on each HTTP call and on the `/ws` connection.

When the backend refuses a token with HTTP 401, curu-comfy-mcp gets a new token and sends the call one more time. When curu-comfy-mcp cannot get a token, each tool for that backend gives `backend_token_failed`, and `list_backends` gives it as the reason.

- A backend has `token_env` or an `oidc` table, not both. Else curu-comfy-mcp stops at start.
- curu-comfy-mcp also stops at start when a backend with an `oidc` table has a user name in its URL. It also stops when that backend is the default and `CURU_COMFY_MCP_BACKEND_TOKEN` is set.
- curu-comfy-mcp stops at start when the file has a `client_secret` key, or when the variable of `client_secret_env` is not set.
- The single-backend variables give no OIDC client. Write the backend in the file.
- The backend sees curu-comfy-mcp as the caller, not the user. With inbound OIDC, the log of curu-comfy-mcp names the user of each job.

[docs/oidc-providers.md](docs/oidc-providers.md) gives the values for each identity provider, and what a backend must check.

### Reach comfyui_curu_auth with an OIDC client

`comfyui_curu_auth` (the gate) 2.1.0 and later accept an OIDC access token, and also the backend token. The owner of the ComfyUI turns this on with two settings of the gate: `COMFYUI_CURU_AUTH_OIDC_ISSUER_URL` and `COMFYUI_CURU_AUTH_OIDC_AUDIENCE`. An older `comfyui_curu_auth` accepts only the backend token, so use `token_env` for it.

1. Ask the owner of the ComfyUI for the issuer and the audience of the gate.
2. Set `issuer` of the `oidc` table to the issuer of the gate.
3. Make the token hold the audience of the gate in `aud`. With rauthy, set `resource` to the audience, and add it to `allowed_resources` of the client. With Authentik, the audience is the client id of the provider of the gate.
4. If the owner of the ComfyUI set `COMFYUI_CURU_AUTH_OIDC_REQUIRED_SCOPES`, give the client each of those scopes.

The gate refuses a token with a lifetime of more than 1 hour, an HS256 token and an ID token. When it refuses a token on an HTTP call, it gives HTTP 401 also during a block. curu-comfy-mcp then gets a new token and sends the call one more time. If the gate refuses the new token too, the tool gives `backend_auth_failed`, not `backend_rate_limited`. A valid token passes also during a block. On `/ws`, curu-comfy-mcp gets a new token and waits 30 seconds before it connects again.

When the gate has no keys of the identity provider, it gives HTTP 503. Then each tool gives `backend_unavailable`, and the message names HTTP 503. A 503 does not make the block longer.

The [provider guide of the gate](https://github.com/darth-veitcher/comfyui_curu_auth/blob/main/docs/oidc-providers.md#let-programs-send-access-tokens) gives the settings for each provider.

## Find the models and the node types of the backend

Read the backend before you write a workflow. Each backend has its own models and node packs, and a workflow with a wrong name fails. These tools only read, and they send no workflow.

1. `list_models` with no folder gives the model folders, for example `checkpoints`, `loras`, `vae` and `text_encoders`.
2. `list_models` with a folder gives the file names of that folder:

   ```json
   {"folder": "checkpoints", "contains": "krea"}
   ```

3. `list_node_types` finds the node types. Give `contains` for the name or the display name, or `category` for the menu path, for example `loaders`.
4. `get_node_type` gives the inputs and the outputs of one node type:

   ```json
   {"node_type": "CLIPLoader"}
   ```

Each node type gives its `name` (the `class_type` of a workflow node), its `display_name`, its `category` and `output_node`. `output_node` is `true` for a node that ends a workflow.

Each input gives `required`, its `type`, its `default`, and its `min` and `max` when it has them. `required` is `false` for an optional input. A list input has the type `COMBO`, and `options` gives its valid values, for example each file that a loader accepts. Each output gives its `index`, the slot of a link such as `["8", 0]`.

Some custom nodes declare a list input as plain text, `STRING`. Then the backend gives no valid values.

The lists of `list_models` and `list_node_types` stop at `limit`, from 1 to 500, with the default 200. `total` gives the count after the filter. `truncated` is `true` when the list stops at the limit: then give a narrower filter.

The valid values of one input stop at 500, and the valid values of all the inputs stop at 1000. `options_total` gives the count of valid values after `options_contains`, and `options_truncated` is `true` when they stop. To find one model file in a long list, give `options_contains`:

```json
{"node_type": "LoraLoader", "options_contains": "krea"}
```

## See the queue and the recent jobs

`get_queue` gives the job that runs now and the waiting jobs, in order. The first waiting job runs next. Other users can have jobs in the same queue. `running` and `pending` give the `job_id` and the `queue_number` of each job. The waiting jobs stop at `limit`, from 1 to 500, with the default 200. `pending_total` gives the count of waiting jobs, and `pending_truncated` is `true` when they stop. `backend` gives the name of the backend of the queue.

`list_recent_jobs` gives the last finished jobs, newest first. `jobs` gives the `job_id`, the `state` (`succeeded` or `failed`) and the `output_count` of each job. `limit` is from 1 to 500, with the default 20. `backend` gives the name of the backend of the jobs.

Neither tool has a text filter, because a job id is a random value. The history of ComfyUI gives no count, so `list_recent_jobs` has `truncated` but no `total`.

## Read and release the VRAM of a backend

`get_system_stats` reads the devices of the backend and the versions of its software. VRAM is the memory of the GPU. Use it to see whether a large model fits before you send a workflow.

```json
{"backend": "default", "system": {"os": "linux", "comfyui_version": "0.27.0", "python_version": "3.12.3", "pytorch_version": "2.7.1+cu128"}, "devices": [{"name": "cuda:0 NVIDIA GeForce RTX 3090 : cudaMallocAsync", "type": "cuda", "index": 0, "vram_total": 25296044032, "vram_free": 19978125312, "torch_vram_total": 6442450944, "torch_vram_free": 12582912}]}
```

- `backend` is the name of the backend that gave the result.
- `devices` gives each device of the backend. ComfyUI sends one device: the device that it runs on. On a computer with no GPU, its `type` is `cpu` and it has no `index`.
- `vram_total` and `vram_free` are the memory of the device and its free part, in bytes. 25296044032 bytes is about 23.6 GiB.
- `torch_vram_total` is the memory that PyTorch reserves for ComfyUI, in bytes. ComfyUI can release at least this memory. It is only a lower bound. Under DynamicVRAM, ComfyUI keeps the model weights outside this memory, and the value reads about 0.
- `torch_vram_free` is the free part of the memory that PyTorch reserves, in bytes.
- `system` gives the operating system, the RAM in bytes and the versions of ComfyUI, Python and PyTorch.
- A field is absent when the backend does not send it. An older ComfyUI sends no `torch_vram_total` and no template versions. A field with a value of the wrong type is absent too.

The result does not give the command line of ComfyUI, because it can hold a secret.

From Python, `await client.system_stats()` gives the same result as a `SystemStats`. `await client.free(unload_models=True, free_memory=True)` releases the VRAM of the backend. `unload_models` unloads the models from the GPU. `free_memory` also clears the cache of the node outputs, so the next job runs each node again. The two arguments have no default. Give them by name.

`free` has an effect on each user of the backend. Their results do not change, but their next job loads its models again. For this reason, no MCP tool releases VRAM. ComfyUI releases the memory on the next loop of its prompt worker, so the VRAM can go down a short time after `free` returns. Read the system stats again to see the change.

## Upload an input image

Upload an image to the backend before a workflow that reads it, for example an image-to-image workflow. Give the bytes of the image to `upload_image` in base64:

```json
{"image": "iVBORw0KGgo=", "name": "cat.png"}
```

The result gives the stored name of the file:

```json
{"name": "cat.png", "folder_type": "input", "backend": "default"}
```

Put the stored name into the `image` input of a `LoadImage` node, then send the workflow:

```json
{"workflow": {"1": {"class_type": "LoadImage", "inputs": {"image": "cat.png"}}, "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0], "filename_prefix": "cat"}}}}
```

- `image` is the base64 text of the bytes of the file, with no `data:` prefix. The text can have line breaks. The tool does not read a file path, because curu-comfy-mcp can run on a different computer.
- `name` is the file name on the backend. With no name, the name is the SHA-256 hash of the bytes and an extension, for example `.png`. Thus the same bytes always get the same name.
- `folder_type` is `input`, `temp` or `output`. The default is `input`, the folder that `LoadImage` reads.
- `subfolder` puts the file into a subfolder of that folder. The stored name then starts with the subfolder, for example `refs/cat.png`.
- `overwrite` is `false` by default. The backend then keeps a file of the same name. For the same bytes, the name stays the same. For other bytes, the backend stores a new file, for example `cat (1).png`.
- Set `overwrite` to `true` to replace a file. Other users of the backend can read the same files, so replace only your own files.
- `backend` names the backend. Upload to the backend that runs the workflow.
- The result `name` is the name that the backend used. Always put that name into the workflow, not the name that you sent.

`CURU_COMFY_MCP_MAX_UPLOAD_BYTES` sets the upload size limit, 25 MiB by default. The limit counts the decoded bytes, not the base64 text. A larger image gives the error `upload_too_large`. A wrong input gives the error `invalid_upload`, for example a name with `/`, `\` or `..`, or text that is not base64. The error table gives each case. For each refused input, curu-comfy-mcp sends nothing to the backend.

From Python, `upload_image` of the client gives the stored name:

```python
from pathlib import Path

from curu_comfy_mcp import ComfyUIClient


async def upload(url: str, path: str) -> str:
    data = Path(path).read_bytes()
    async with ComfyUIClient(url) as client:
        return await client.upload_image(data)
```
## Send a workflow

`submit_workflow` sends a workflow in the ComfyUI API format to the backend. In the ComfyUI web UI, use "Export (API)" to get this format. Each key is a node id. Each value has `class_type` and `inputs`.

```json
{"workflow": {"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "cat"}}}}
```

The result gives the job id and the queue number:

```json
{"job_id": "8f1c2e7a-0d5b-4e43-9a51-2c7e6f0b9d14", "queue_number": 3, "backend": "default"}
```

- `job_id` is the ComfyUI prompt id. Give it to the other tools.
- `queue_number` is the number that ComfyUI gives to the job. A higher number is a later job.
- `backend` is the name of the backend that got the workflow. With no configuration file, the one backend is `default`.

curu-comfy-mcp sends the workflow unchanged. It does not check the nodes or the models. ComfyUI checks the workflow, and curu-comfy-mcp gives each refusal back to the agent.

## Watch a job

`get_job` reads the state of a job now. `wait_for_job` waits until the job is finished, or until its timeout comes.

```json
{"job_id": "8f1c2e7a-0d5b-4e43-9a51-2c7e6f0b9d14", "timeout_seconds": 300}
```

The `state` of a job is one of these values:

| State | Meaning | Other fields |
|---|---|---|
| `queued` | The job waits in the queue. | `queue_position`: 1 is the next job to run. |
| `running` | The backend runs the job now. | `node_id`, `node_type`, `progress_value`, `progress_max` |
| `succeeded` | The job finished with no error. | `outputs`: each output file. The list can be empty. |
| `failed` | A node failed, or the ComfyUI `/interrupt` call stopped the job. | `failure` |
| `unknown` | The backend has no job with this id. A job that `cancel_job` removed from the queue is also `unknown`. | none |

For a running job, `progress_value` is the current step of the node, and `progress_max` is its last step. For example, a sampler with 20 steps goes from 1 to 20. Some nodes send no progress, so these two fields are not always there. Each node starts at step 1 again.

`timeout_seconds` is from 1 to 3600, and the default is 300. After a timeout, the result gives the state `queued` or `running`, and the job continues. Call `wait_for_job` again to wait more.

When the MCP client asks for progress, `wait_for_job` sends a progress notification for each change. Example: "Job 8f1c… is running node 3 (KSampler): step 12/20."

## Cancel a job

`cancel_job` stops one job. It removes a queued job from the queue, or it interrupts a running job. Give the `job_id`. You can also give the `backend`, as for `get_job`.

```json
{"job_id": "8f1c2e7a-0d5b-4e43-9a51-2c7e6f0b9d14"}
```

The result gives the `outcome`:

```json
{"job_id": "8f1c2e7a-0d5b-4e43-9a51-2c7e6f0b9d14", "backend": "default", "outcome": "interrupted"}
```

| Outcome | Meaning | What to do next |
|---|---|---|
| `interrupted` | The job ran, and curu-comfy-mcp asked the backend to stop it. | Call `wait_for_job`. The state is `failed` with the cause `interrupted`. It is `succeeded` when the job finished before the stop. |
| `dequeued` | The job waited in the queue, and curu-comfy-mcp removed it. The job does not run. | Nothing. `get_job` now gives the state `unknown`. |
| `not_found` | The job is not in the queue now. It finished before or during the cancel, or the backend does not know it. curu-comfy-mcp changed nothing. | Nothing. This is not an error. |

On ComfyUI v0.27.0 or later, a cancel never stops a job with a different id. curu-comfy-mcp first reads the queue of the backend. It interrupts the job only when the job runs, and it sends the job id with the interrupt. ComfyUI v0.27.0 then stops the running job only when it has that id. curu-comfy-mcp never sends an interrupt with no job id, and never clears the queue.

Each caller who can see a job id can cancel that job, and that includes a job of another user. `get_queue` and `list_recent_jobs` show the id of each job. The tool has the MCP annotation `destructive_hint`, so the MCP client can ask you before it calls the tool.

A second cancel of the same job gives `not_found`. If the backend is down, the result is the error `backend_unavailable`, not `not_found`.

## Get the output files

The `outputs` of a succeeded job give each file:

```json
{"node_id": "9", "filename": "cat_00001_.png", "subfolder": "", "folder_type": "output", "media_type": "image/png"}
```

- `folder_type` is `output` for a saved file, or `temp` for a preview.
- `subfolder` is the sub-folder in that folder. It can be empty.
- `media_type` comes from the file extension. A `media_type` that starts with `image/` gives image content.

Give the `job_id`, the `node_id` and the `filename` to `get_output_file`. An image comes back as MCP image content, and the client can show it. Another file, for example a video, comes back as an MCP embedded resource with its bytes in base64.

`get_output_file` gives only a file from the outputs of that job. It cannot read other files from the backend.

## Use curu-comfy-mcp as a library

Use the ComfyUI client from your own Python program. The client needs no MCP, no configuration file and no `CURU_COMFY_MCP_` variable. It runs in `asyncio`. The `/ws` listener uses the asyncio client of `websockets`, so the client does not run in `trio`.

```python
import anyio

from curu_comfy_mcp import ComfyClientError, ComfyUIClient, Frame

WORKFLOW = {
    "1": {
        "class_type": "EmptyImage",
        "inputs": {"width": 64, "height": 64, "batch_size": 1, "color": 0},
    },
    "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0], "filename_prefix": "lib"}},
}


async def run(url: str, token: str | None, workflow: dict) -> None:
    async def on_frame(frame: Frame) -> None:
        print("frame", frame.type)

    async with ComfyUIClient(url, token=token) as client:
        try:
            job_id, _ = await client.submit(workflow)
            state = await client.wait(job_id, on_frame=on_frame)
            for output in state.outputs or []:
                content = await client.output_file(
                    job_id, output.node_id, output.filename, 10_000_000
                )
                print(output.filename, content.content_type, len(content.data))
        except ComfyClientError as error:
            print(error.code, error.message)


anyio.run(run, "http://127.0.0.1:8188", None, WORKFLOW)
```

- `ComfyUIClient(url, token=...)` makes a client for one backend. For a backend behind `comfyui_curu_auth`, give its token. For a token that changes, give `token_source=` in its place (see below).
- `async with` opens the `/ws` connection for the life of the client. Make one client for each backend, and keep it open.
- `submit` sends the workflow unchanged, and gives the job id and the queue number.
- `wait` returns the state of the job when it is finished. With no time limit, it waits for as long as the job runs. Give a number of seconds as the second argument to stop the wait earlier. After that time, the job continues, and the state is `queued` or `running`.
- `on_frame` gets each `/ws` frame of the job, in order. `frame.type` is the type that ComfyUI sends, for example `progress_state`, `progress`, `executing` or the type of a custom node. `frame.data` is its data. Use the frames to see that a job still runs, for example to reset a stall window. A frame that arrives before `wait` starts does not reach `on_frame`.
- `on_change` gets each new state of the job, as `wait_for_job` reports it.
- `cancel(job_id)` stops the job, as `cancel_job` does, and gives a `CancelResult`. Its `outcome` is `interrupted`, `dequeued` or `not_found` (see [Cancel a job](#cancel-a-job)). Its `backend` is `None`.
- `upload_image(data, name=None, *, folder_type="input", subfolder="", overwrite=False, max_bytes=None)` stores an image, and gives the stored name for a `LoadImage` node (see [Upload an input image](#upload-an-input-image)). `max_bytes` is your own size limit, or `None` for no limit. A wrong input raises `InvalidUpload`, and a larger image raises `UploadTooLarge`. Neither sends a request.
- `system_stats` gives the devices and the versions of the backend, and `free` releases its VRAM (see [Read and release the VRAM of a backend](#read-and-release-the-vram-of-a-backend)).
- `output_file` gives an `OutputContent`. Its fourth argument is the size limit in bytes. A larger file raises `OutputTooLarge`. `content.data` holds the bytes. `content.content_type` is the `Content-Type` header that the backend sent, or `None` when it sent no header. `content.file.media_type` comes from the file extension.
- Each failure raises a subclass of `ComfyClientError`. Its `code` is a code of [Handle errors](#handle-errors), for example `backend_unavailable`.

A token source is an object with two methods. `async def token(self) -> str` gives the current token. `def invalidate(self, token: str) -> None` drops a token that the backend refused. The client asks for the token before each HTTP call and each `/ws` connection. After HTTP 401 on an HTTP call, it calls `invalidate` and tries one more time with a new token. After HTTP 401 on the `/ws` handshake, it calls `invalidate` and connects again after 30 seconds.

To use the named backends of a configuration file, read the settings with `Settings.load()`. It reads the file and the environment, as the command does, and raises `ConfigError` for a bad value. Then use `async with Backends(settings) as backends:`, and get a client with `await backends.client("orange")`. Give `None` in place of a name to get the client of the default backend. A backend with an `oidc` table gets its token from a `ClientCredentials` token source.

Import each name from `curu_comfy_mcp`. The names in `curu_comfy_mcp.__all__` are the public names. Each other name and each module path can change in a minor version.

## Handle errors

Each failure of a tool is an error result. Its text is one JSON object with `code`, `message` and, for some codes, `details`. The agent reads `code` to decide what to do.

| Code | Cause | What to do |
|---|---|---|
| `invalid_workflow` | The workflow is not a JSON object. | Send an object in the API format. |
| `workflow_refused` | ComfyUI refused the workflow. `details.node_errors` names each node and input. | Fix each node, then send the workflow again. |
| `backend_unavailable` | curu-comfy-mcp cannot reach the backend, or the backend gives an HTTP error. `details.backend_url` gives the URL, and `message` gives the reason. | Start ComfyUI, or correct the URL. |
| `backend_auth_failed` | The backend refused the backend token of curu-comfy-mcp (HTTP 401 or 403). `details.status` gives the status. | Ask the operator to correct the backend token and to start curu-comfy-mcp again. The token is in the variable of `token_env`, or in `CURU_COMFY_MCP_BACKEND_TOKEN` (see [Reach a backend behind comfyui_curu_auth](#reach-a-backend-behind-comfyui_curu_auth)). For a backend with an OIDC client, ask the operator to check its `oidc` table: `audience`, `resource` and `scope`. |
| `backend_token_failed` | curu-comfy-mcp could not get an access token for the backend from its identity provider. `details.backend` names the backend. `details.token_endpoint` gives the token endpoint, or the issuer when curu-comfy-mcp could not read the discovery document. `details.error` gives the OAuth error code, for example `invalid_client`, or null. | Ask the operator to check `client_id`, the client secret and the identity provider (see [Reach a backend with its own OIDC client](#reach-a-backend-with-its-own-oidc-client)). |
| `backend_rate_limited` | `comfyui_curu_auth` blocks curu-comfy-mcp for a time after a wrong backend token (HTTP 429). `details.retry_after_seconds` gives the wait: from 1 to 301 seconds with `comfyui_curu_auth`, longer after more wrong tokens. It is null when the backend sends no `Retry-After`. | Wait that time, then try again. |
| `unknown_backend` | The configuration has no backend with this name. `details.known_backends` lists the names. | Use a name from that list, or call `list_backends`. |
| `unknown_job` | `wait_for_job` or `get_output_file` got a job id that the backend does not know. `get_job` gives the state `unknown` in its place. | Use a job id from `submit_workflow`. |
| `unknown_model_folder` | The backend has no model folder with this name. `details.known_folders` lists the folders. | Use a folder from that list. |
| `unknown_node_type` | The backend has no node type with this name. | Find the name with `list_node_types`. |
| `unknown_output` | The job has no output with this node and file name. | Use a `node_id` and a `filename` from `outputs`. |
| `job_not_finished` | The job is queued, running or failed, so it has no outputs. | For a queued or running job, call `wait_for_job` first. For a failed job, read `failure`. |
| `output_too_large` | The file is larger than the size limit. | Ask the operator to set a larger `CURU_COMFY_MCP_MAX_OUTPUT_BYTES` and to start curu-comfy-mcp again (see [Configure curu-comfy-mcp](#configure-curu-comfy-mcp)). |
| `invalid_upload` | curu-comfy-mcp refused an input of `upload_image`, and sent nothing to the backend. `details.field` names the input: `image`, `name` or `subfolder`. `details.value` gives a refused name or subfolder. | For `image`, send the bytes in base64, with no `data:` prefix. For `name`, give a file name with no `/`, `\` or `..`, or no name. For `subfolder`, give parts with `/` between them, and no `\` or `..`. |
| `upload_too_large` | The decoded image is larger than the upload size limit. curu-comfy-mcp sent nothing to the backend. `details.size_bytes` and `details.max_upload_bytes` give the two sizes. | Send a smaller image, or ask the operator to set a larger `CURU_COMFY_MCP_MAX_UPLOAD_BYTES` and to start curu-comfy-mcp again (see [Configure curu-comfy-mcp](#configure-curu-comfy-mcp)). |

A refusal also gives data that helps the agent. For a list input with a wrong value, the message says "Value not in list" and gives each valid value. ComfyUI checks only the output nodes, such as `SaveImage`, and the nodes upstream of them. A node with no path to an output node gives no error and does not run.

A refused workflow gives an error like this:

```json
{"code": "workflow_refused", "message": "ComfyUI refused the workflow. Fix each node in details.node_errors.", "details": {"error": "Prompt outputs failed validation", "node_errors": [{"node_id": "4", "node_type": "CheckpointLoaderSimple", "input_name": "ckpt_name", "message": "Value not in list: ckpt_name: 'x.safetensors' not in [...]"}]}}
```

A node that fails while it runs does not give an error result. The job gets the state `failed`, and `failure` gives the cause:

| Field | Meaning |
|---|---|
| `code` | `node_failed` for an exception in a node. `interrupted` when the ComfyUI `/interrupt` call stopped the job. |
| `node_id`, `node_type` | The node that failed. These fields are absent when ComfyUI does not give them. |
| `exception_type` | The Python exception, for example `torch.OutOfMemoryError` when the GPU has too little memory. This field is absent when ComfyUI does not give it. |
| `message` | For `node_failed`, the exception message, at most 500 characters. For `interrupted`, a fixed text. |

## Configure curu-comfy-mcp

curu-comfy-mcp reads its settings when it starts, from four layers. A higher layer replaces a lower layer value by value:

1. The command-line options `--config` and `--backend`.
2. The environment variables below.
3. The configuration file. curu-comfy-mcp reads `--config`, else `CURU_COMFY_MCP_CONFIG`, else `$XDG_CONFIG_HOME/curu/curu-comfy-mcp/config.toml`. With no `XDG_CONFIG_HOME`, it reads `~/.config/curu/curu-comfy-mcp/config.toml`.
4. The defaults.

A file that `--config` or `CURU_COMFY_MCP_CONFIG` names must exist. A missing file at the default location is not an error. The file holds `default_backend`, `max_output_bytes`, `max_upload_bytes` and the `[backends.<name>]` tables, with `url` and `token_env` (see [Use more than one backend](#use-more-than-one-backend)). It can also hold the `[oidc]` table (see [Sign in to curu-comfy-mcp with OIDC](#sign-in-to-curu-comfy-mcp-with-oidc)). Each backend can have an `oidc` table (see [Reach a backend with its own OIDC client](#reach-a-backend-with-its-own-oidc-client)). An unknown key stops curu-comfy-mcp, so that you find a typing error at start.

An empty variable has the same effect as an unset variable.

| Variable | Default | Meaning |
|---|---|---|
| `CURU_COMFY_MCP_CONFIG` | none | The path of the configuration file. The file must exist. |
| `CURU_COMFY_MCP_DEFAULT_BACKEND` | none | The name of the backend for a tool call with no backend name. |
| `CURU_COMFY_MCP_BACKEND_URL` | `http://127.0.0.1:8188` | The URL of the default backend. It must be an `http://` or `https://` URL with a host. With no file, it describes the one backend, `default`. |
| `CURU_COMFY_MCP_MAX_OUTPUT_BYTES` | `26214400` (25 MiB) | The largest output file that `get_output_file` sends. It must be a whole number of bytes, more than 0. |
| `CURU_COMFY_MCP_MAX_UPLOAD_BYTES` | `26214400` (25 MiB) | The largest image that `upload_image` sends, after curu-comfy-mcp decodes the base64. It must be a whole number of bytes, more than 0. |
| `CURU_COMFY_MCP_HTTP_TOKEN` | none | The inbound token for HTTP. It must have 32 characters or more. On stdio, curu-comfy-mcp does not read it. |
| `CURU_COMFY_MCP_OIDC_ISSUER` | none | The issuer of the OIDC access tokens. With no issuer, OIDC is off. It replaces `issuer` of `[oidc]`. |
| `CURU_COMFY_MCP_OIDC_RESOURCE` | none | The URL at which clients reach `/mcp`. OIDC needs it. It replaces `resource` of `[oidc]`. |
| `CURU_COMFY_MCP_OIDC_AUDIENCE` | the resource URL | The value that `aud` of each token must hold. It replaces `audience` of `[oidc]`. |
| `CURU_COMFY_MCP_OIDC_TOKEN_TYPES` | `at+jwt` | The values of `typ` that curu-comfy-mcp accepts, with a comma between two values, for example `at+jwt,JWT`. It replaces `token_types` of `[oidc]`. |
| `CURU_COMFY_MCP_OIDC_REQUIRED_SCOPES` | none | The scopes that each token must have, with a space between two scopes. It replaces `required_scopes` of `[oidc]`. |
| `CURU_COMFY_MCP_BACKEND_TOKEN` | none | The bearer token of the default backend, behind `comfyui_curu_auth`. With no value, curu-comfy-mcp sends no `Authorization` header to it. |

The MCP client sends each file through the conversation of the agent, so a large file uses much of its context. Keep the limit low unless the agent needs large files, for example videos.

`CURU_COMFY_MCP_BACKEND_URL` can hold a user name and a password, for a reverse proxy with Basic authentication. A URL in the file cannot hold a password, because no secret goes into the file. curu-comfy-mcp sends the user name and the password in the `Authorization` header. Thus a backend cannot have both a user name in its URL and a token.

If a value is not correct, `curu-comfy-mcp` writes one line to stderr and stops with exit code 78. This is `EX_CONFIG` of `sysexits.h`, the usual code for a configuration error. HTTP without a valid token or an issuer also gives exit code 78. An unknown option or a port outside 1 to 65535 gives exit code 2, the usual code for a usage error. If the port is in use, curu-comfy-mcp writes the address to stderr and stops with exit code 3.

## Limits

- curu-comfy-mcp does not select a backend by load or by cost. The agent names the backend, or the default applies.
- curu-comfy-mcp gets a backend token only with the client credentials grant. It does not use token exchange, so a backend sees curu-comfy-mcp, not the user.
- curu-comfy-mcp cannot use Google as the identity provider of a backend, because Google has no client credentials grant.
- curu-comfy-mcp gives no access by user or by group. Each valid token with the required scopes can use each tool. It has no user accounts.
- curu-comfy-mcp reads only JWT access tokens signed with RS256 or ES256. It cannot read an opaque token, so it does not support Google as the issuer.
- curu-comfy-mcp gives no tokens and serves no login page. The identity provider does that.
- curu-comfy-mcp has no TLS. Use a reverse proxy for TLS.
- curu-comfy-mcp does not check a workflow before it sends it. Read the backend with the read tools, and let ComfyUI check the workflow.
- A list of the read tools stops at 500 items. A larger list needs a filter.
- curu-comfy-mcp does not start, stop or install a backend.
- curu-comfy-mcp records no owner of a job. Each caller who can see a job id can cancel that job.
- On a ComfyUI older than v0.27.0, the backend ignores the job id of an interrupt. If the job finishes just before the interrupt, the interrupt can stop the next job of the queue. That job can be the job of a different user.
- curu-comfy-mcp cannot delete an uploaded file, because ComfyUI has no call for that. It does not upload masks with `/upload/mask`.
- No MCP tool releases the VRAM of a backend. Only the library can call `free`.
- `free` does not wait until the VRAM goes down. Read the system stats again to see it.
- The library does not keep the `/ws` frames of a job that arrive before `wait` starts. Call `wait` at once after `submit`.
- curu-comfy-mcp keeps no data. After a restart, it reads each job state from the history of the backends. If ComfyUI restarts, its history is empty, and each old job becomes `unknown`.
- After a restart, if the backend of a job is off, a call with no backend name cannot find the job. `get_job` then gives the state `unknown`, and `wait_for_job` and `get_output_file` give `unknown_job`. If the default backend is off, the call gives `backend_unavailable`.

## Technical notes

- The project was comfy-mcp before 1.0.0. The command does not read the old `COMFY_MCP_` variables or `~/.config/curu/comfy-mcp/config.toml`. Move the file to `~/.config/curu/curu-comfy-mcp/config.toml` ([ADR-014](project-management/ADRs/ADR-014-the-project-is-curu-comfy-mcp.md)).
- curu-comfy-mcp uses the official MCP Python SDK, version 2.3, and the `websockets` package. One install gives the command and the library ([ADR-012](project-management/ADRs/ADR-012-one-plain-install.md)). The library imports no MCP, so a program that uses it does not load the SDK.
- The public library surface is `curu_comfy_mcp.__all__`. Semantic versioning covers those names, the tool names and arguments, the result fields, the error codes and the settings. The names load at their first use, so the command still starts fast ([ADR-008](project-management/ADRs/ADR-008-public-library-surface-and-semver.md)). The contract is in [contracts/public-api.md](specs/008-library-surface/contracts/public-api.md).
- A test compares the public surface with a committed snapshot that holds the version of the package. A change of the surface needs a new version and a changelog section ([ADR-013](project-management/ADRs/ADR-013-a-snapshot-of-the-semver-surface.md)). The snapshot has no descriptions, because semantic versioning does not cover them. The contract is in [contracts/surface-snapshot.md](specs/012-the-schema-contract-and-the-v1-0-release/contracts/surface-snapshot.md).
- A wait gets each `/ws` frame of its job. A frame that does not change the state does not move the next read of the history. Thus `wait_for_job` reads the backend as often as before.
- It uses these ComfyUI calls: `POST /prompt`, `GET /queue`, `POST /queue`, `POST /interrupt`, `GET /history/{prompt_id}`, `GET /history?max_items=N`, `GET /view`, `GET /ws`, `GET /models`, `GET /models/{folder}`, `GET /object_info`, `GET /object_info/{node_type}`, `GET /system_stats`, `POST /free` and `POST /upload/image`. The wire formats come from ComfyUI v0.27.0.
- A cancel reads `GET /queue` first. For a running job, it sends `POST /interrupt` with `{"prompt_id": ...}`. For a queued job, it sends `POST /queue` with `{"delete": [...]}`, then reads `GET /queue` again, because the job can start in that time. If the job runs then, it sends `POST /interrupt` with its `prompt_id`, and the outcome is `interrupted`. If the job is in the history, it ran to its end, and the outcome is `not_found`. Else the outcome is `dequeued`. The trust boundary is the backend: each caller who can see a job id can cancel that job ([ADR-010](project-management/ADRs/ADR-010-one-trust-boundary-for-each-backend.md)).
- The history and the queue give the state of a job. `/ws` gives only the running node and its progress, and wakes each wait. When `/ws` is lost, curu-comfy-mcp connects again, and the history still gives the final state.
- `free` is a library call only, with no MCP tool, because `/free` unloads the models of each user of the backend ([ADR-011](project-management/ADRs/ADR-011-free-is-a-library-call-only.md)).
- The design has three layers: the ComfyUI client, the MCP server and the command. Only the command knows the transport ([ADR-001](project-management/ADRs/ADR-001-transport-free-tool-layer.md)).
- Each tool returns a failure as an error result with a JSON text, not as a `ToolError` ([ADR-002](project-management/ADRs/ADR-002-tool-failures-as-error-results.md)).
- The inbound token check uses the token-verifier hook of the MCP SDK, and compares the token in constant time ([ADR-003](project-management/ADRs/ADR-003-static-inbound-bearer-token.md)).
- The OIDC check uses pyjwt. It reads the discovery document and the JWKS with httpx2, and never turns off the TLS check. The SDK serves the metadata of RFC 9728, and curu-comfy-mcp checks `aud` itself ([ADR-007](project-management/ADRs/ADR-007-oidc-for-clients-and-backends.md)). The contract is in [contracts/auth.md](specs/006-oidc-inbound/contracts/auth.md).
- The backend token goes on the HTTP client and on the `/ws` handshake. After a handshake refused with HTTP 401 or 403, `/ws` waits 30 seconds. After HTTP 429, it waits the `Retry-After` time. After a different refusal, for example 502, it keeps the fast schedule. Progress can stop for the time of a wait. The history still gives the final state ([ADR-004](project-management/ADRs/ADR-004-backend-bearer-token.md)). The error contract is in [contracts/errors.md](specs/003-backend-token/contracts/errors.md).
- On HTTP, the access log shows each request path without its query string. A token that a client puts in the query string by mistake does not go into the log.
- When `--host` is `127.0.0.1`, `localhost` or `::1`, the SDK also accepts only local `Host` and `Origin` headers. This protects against DNS rebinding from a web page.
- The configuration has four layers in one order: the flags, the environment, the file and the defaults. The file is in the directory of the curu tools ([ADR-006](project-management/ADRs/ADR-006-configuration-hierarchy-and-location.md)). The contract is in [contracts/config.md](specs/005-multi-backend/contracts/config.md).
- The token of a backend comes from `curu_comfy_mcp.oauth.ClientCredentials`. An `httpx2` auth flow puts the current token on each HTTP call, and `/ws` asks for it before each handshake. After a `/ws` handshake that the backend refuses with HTTP 401, curu-comfy-mcp drops the token. The contracts are in [contracts/config.md](specs/007-backend-client-credentials/contracts/config.md) and [contracts/errors.md](specs/007-backend-client-credentials/contracts/errors.md).
- curu-comfy-mcp opens a client for a backend at the first tool call that uses it. `list_backends` reads `GET /queue` and opens no `/ws`.
- The lists of `list_models` and `list_node_types` have a filter, a limit, `total` and `truncated`. `get_queue` has a limit, `pending_total` and `pending_truncated`. `list_recent_jobs` has a limit and `truncated` ([ADR-005](project-management/ADRs/ADR-005-bounded-list-results.md)).
- The tool contract is in [contracts/tools.md](specs/001-stdio-job-tracer/contracts/tools.md). The contract of the read tools is in [contracts/tools.md](specs/004-backend-introspection/contracts/tools.md). The command and HTTP contract is in [contracts/http.md](specs/002-streamable-http/contracts/http.md).
- The license is MIT. See [LICENSE](LICENSE).

## Develop

```bash
git clone https://github.com/darth-veitcher/curu-comfy-mcp
cd curu-comfy-mcp
uv sync
uv run pytest
```

`uv run pytest` runs the default tests against a fake ComfyUI on a loopback port. They need no network, no GPU and no model. To run the live test against a real ComfyUI:

```bash
CURU_COMFY_MCP_BACKEND_URL=http://<host>:8188 uv run pytest -m integration
```

Run the quality gates before a pull request:

```bash
uv run ruff check --fix && uv run ruff format
uv run ty check
beacon doctor --strict
```

A test keeps the public surface the same at each version. The public surface is each item of [semantic versioning](#glossary): the tools, the results, the error codes, the public names and the settings. The [snapshot](#glossary) of that surface is in `tests/contract/surface.json`. When you change an item, the test fails and shows each change. Then do these steps:

1. Increase `__version__` in `src/curu_comfy_mcp/__init__.py`. Increase MAJOR for a removed or changed item, or for a new required argument. Increase MINOR for a new optional item.
2. Add a section for the new version to `CHANGELOG.md`.
3. Write the snapshot again:

```bash
uv run python -m tests.support.surface
```

The command prints each difference. It writes nothing when the surface changed and the version did not. If no tag has that version yet, add `--same-version`. Do not edit the snapshot by hand.

[AGENTS.md](AGENTS.md) gives the rules for each change.

## Glossary

- **Access token**: a token that an identity provider gives to a client. The client sends it to a service as a bearer token.
- **Agent**: an AI model that calls tools, for example Claude in Claude Code.
- **API format**: the JSON form of a ComfyUI workflow that the `/prompt` call accepts. The web UI makes it with "Export (API)".
- **Backend**: one ComfyUI server that curu-comfy-mcp sends jobs to. Each backend has a name in the configuration.
- **Backend token**: the bearer token that curu-comfy-mcp sends to the backend. `comfyui_curu_auth` calls it the credential.
- **Bearer token**: a secret that a client sends in the `Authorization` header to prove that it may connect.
- **Audience**: the service that a token is for. It is in the `aud` claim of the token.
- **base64**: a text encoding of binary bytes.
- **Claim**: one value in a JWT, for example `iss` or `exp`.
- **Client credentials grant**: the OAuth way for a program to get an access token with its own id and secret, with no user.
- **Configuration file**: the TOML file that names the backends of curu-comfy-mcp. By default it is at `$XDG_CONFIG_HOME/curu/curu-comfy-mcp/config.toml` or `~/.config/curu/curu-comfy-mcp/config.toml`.
- **Default backend**: the backend of a tool call that gives no backend name.
- **Device**: a GPU or a CPU that ComfyUI runs the models on.
- **Discovery document**: the JSON document at `<issuer>/.well-known/openid-configuration`. It gives the addresses of an identity provider.
- **DNS rebinding**: an attack in which a web page in a browser sends requests to a server on the local computer.
- **DynamicVRAM**: a mode of ComfyUI that keeps the model weights outside the memory of PyTorch.
- **Embedded resource**: an MCP result part that holds a file, its URI and its media type.
- **Frame**: one message that ComfyUI sends on its `/ws` connection. The frame of a job has the job id in its data.
- **Gate**: `comfyui_curu_auth`, a ComfyUI custom node that refuses each call without a valid backend token or access token.
- **Inbound token**: the bearer token that each HTTP client sends to curu-comfy-mcp. It is not a credential of a backend.
- **Identity provider**: a server that signs in users and programs, and gives them access tokens, for example Authentik or rauthy.
- **Image content**: an MCP result part that holds an image and its media type. The client can show it.
- **Interrupt**: a request to ComfyUI to stop the job that runs now. ComfyUI stops it at its next check: usually the next sampler step, or the start of the next node.
- **Issuer**: the URL that names an identity provider. It is in the `iss` claim of each token.
- **Job**: one run of a workflow on the backend. Its id is the ComfyUI prompt id.
- **JWKS**: the JSON Web Key Set, the public keys of an identity provider. curu-comfy-mcp checks the signature of a token with them.
- **JWT**: a JSON Web Token, a signed token with claims. curu-comfy-mcp reads JWT access tokens.
- **List input**: a node input with a fixed list of valid values.
- **MCP**: the Model Context Protocol, the protocol that an agent uses to find and call tools.
- **Model folder**: a kind of model file on the backend, for example `checkpoints`.
- **Node**: one step of a workflow, for example a sampler or an image save.
- **Node pack**: a set of custom node types that a person installs in ComfyUI.
- **Node type**: a kind of node that the backend can run, for example `KSampler`. Its name is the `class_type` of a workflow node.
- **Output node**: a node that ends a workflow, for example `SaveImage`.
- **OIDC**: OpenID Connect, a sign-in standard on top of OAuth 2.0.
- **Opaque token**: an access token that only its identity provider can read. It is not a JWT.
- **Operator**: the person who configures and starts curu-comfy-mcp in the MCP client.
- **Progress notification**: an MCP message that tells the client how far a tool call is.
- **Public surface**: each item that semantic versioning covers. These are the tools and their results, the error codes and the names in `curu_comfy_mcp.__all__`. They also include the command options, the variables and the configuration keys. The public library surface, `curu_comfy_mcp.__all__`, is one part of it.
- **Protected-resource metadata**: the document of RFC 9728 that tells an MCP client the issuer and the scopes of curu-comfy-mcp.
- **Resource server**: a service that accepts access tokens and checks them. curu-comfy-mcp is one on HTTP.
- **Reverse proxy**: a server, for example Caddy or nginx, that receives the requests and sends them to curu-comfy-mcp.
- **Scope**: a name for a right that a token gives, for example `profile`.
- **Semantic versioning**: a version of three numbers, MAJOR.MINOR.PATCH. A change that can break a caller increases MAJOR. A new feature increases MINOR.
- **SHA-256**: a hash function. It gives a value of 64 hex characters for a set of bytes. In practice, different bytes give a different value.
- **Snapshot**: a committed file that holds the public surface at one version. A test compares the code with it.
- **Stall window**: the time that a caller waits for a sign of life from a job, before it stops the job.
- **stdio**: standard input and output. The MCP client and the server talk through them.
- **Stored name**: the name of an uploaded file on the backend, with its subfolder. A `LoadImage` node reads the file with this name.
- **Streamable HTTP**: the MCP transport over HTTP. A client sends each request to one endpoint, and the server can stream the answer.
- **TLS**: the encryption of HTTP traffic, as in HTTPS.
- **Token source**: an object that gives the current token of one backend (`token`), and drops a token that the backend refused (`invalidate`).
- **Trust boundary**: the set of callers that can change the jobs of the other callers. For curu-comfy-mcp, it is each caller who can see a job id on a backend.
- **Upstream**: a node is upstream of a different node when its output goes to that node, directly or through other nodes.
- **VRAM**: the memory of a GPU. A model must be in VRAM to run on the GPU.
- **Workflow**: a graph of nodes that ComfyUI runs.
