---
name: spectra-mcp
description: How an agent works with the Spectra MCP server - pick a model, build its input from the JSON Schema, get a file into storage (yours to read, or the person's to hand over), start a generation, wait for the result, and handle a job that failed. Use on any call to list_models, get_model_schema, generate, get_job, cancel_job, upload_file, start_upload, upload_chunk, finish_upload, request_upload, claim_upload, get_upload_url.
---

# Running Spectra generations over MCP

## What this server is

Twelve tools on `POST /api/mcp` (Streamable HTTP). The server keeps no history: no job
list, no "list my generations". Everything you start exists only under the `job_id`
returned by `generate` — **lose it and the job cannot be reached over MCP again**.

The one thing it does hold between calls is a chunked upload in progress: `start_upload`
opens an assembly that lives in the server's memory, belongs to your key, and is dropped
after its idle timeout. Finish it in the same run of calls; do not park a `session_id`
and come back to it later.

`generate` spends the user's credits. Run only what you were asked for, one job at a
time, and never "just to see what happens".

## The order of operations (do not shortcut)

1. `list_models` — find the model, take its `id`.
2. `get_model_schema` — get the `inputSchema` and `capabilities`
   **of that exact model**.
3. If the input includes a file: get it into storage first — see **Files**, below.
4. `generate` — get the `job_id`.
5. Wait for the result — the generation panel follows the job where the client shows
   it; otherwise `get_job` with `waitSeconds=25`. See **Waiting for the result**.

Step 2 is never skipped. Parameter names and allowed values come from the live node
definition in the main backend: they differ between models and change over time. Input
assembled from memory, or by analogy with another model, is a guaranteed validation
error — not a saved call.

## Tools

| Tool | Arguments | Returns | Token |
|---|---|---|---|
| `list_models` | `category?`: `image` \| `video` \| `audio` \| `text` \| `3d` \| `other` | `{models: [{id, id_global, name, category, description, deprecated}]}` | `tasks:read` |
| `get_model_schema` | `model`: slug or `id_global` | `{model, id_global, name, category, description, deprecated, averageTimeInSeconds, inputSchema, capabilities: {rules}, outputs}` | `tasks:read` |
| `generate` | `model`, `input` (flat object) | `{job_id, status: "queued", model}` | `tasks:write` |
| `get_job` | `jobId`, `waitSeconds?` (0–25, server waits for the job) | `{job_id, status, position, error?, result?, outputs?}` plus `resource_link` blocks for media | `tasks:read` |
| `cancel_job` | `jobId` | `{job_id, canceled: true}` | `tasks:write` |
| `get_upload_url` | — | `{upload_url, method, content_type, file_field, auth, result_field}` | `files:write` |
| `upload_file` | `filename`, `mime_type`, `data_base64` | `{storage_object_id, filename, mime_type, bytes}` | `files:write` |
| `start_upload` | `filename`, `mime_type`, `total_bytes?` | `{session_id, chunk_max_bytes, max_total_bytes, expires_in_seconds, next_step}` | `files:write` |
| `upload_chunk` | `session_id`, `index`, `data_base64` | `{session_id, status, received_chunks, received_bytes, next_index, next_step}` | `files:write` |
| `finish_upload` | `session_id` | `{storage_object_id, filename, mime_type, bytes}` | `files:write` |
| `request_upload` | — | `{status, upload_url, ticket, expires_in_seconds, max_bytes, next_step}` | `files:write` |
| `claim_upload` | `code` | `{storage_object_id, filename, mime_type, bytes}` | `files:write` |

The catalog (`list_models`, `get_model_schema`) is read with the server's service token
and works even without user authorization. Everything else runs on the user's account
and is refused without their token.

Not every model is in the MCP catalog: some are REST-only. If a model is not in
`list_models`, passing its slug directly will not run it either.

## Building `input`

`input` is **one flat object**: its keys are the `properties` of `inputSchema`. The
server splits them into inputs and params itself; the `x-…-group` key in the schema is
only there in case you want to show that split to the user.

Rules enforced before anything reaches the backend:

- **No extra keys.** A name that is not in the schema → `unknown field for this model`.
- **`required`** lists the fields that have no default. Everything else can be omitted —
  the server fills in the node's defaults. Do not restate a default "to be safe".
- **`null` means "not supplied".** The field falls back to its default; if it has no
  default and is required, you get `is required`.
- **`enum`** — strictly one of the listed values. These are technical codes
  (`sec_5`, `r_16_9`, `kling_v2_master`), not what a human is shown; never translate
  them into "5 seconds" and back.
- **Numbers** — respect `minimum` / `maximum` / `multipleOf` and the type
  (`integer` vs `number`).
- **File fields** (`anyOf: [integer, string/uri]`) accept either the
  `storage_object_id` of an uploaded file or a public `http(s)` URL. A local path,
  `file://`, or `data:` is not accepted.
- **Arrays** — respect `maxItems`; each item of a file list follows the rule above.
- File fields deliberately ship no `default`: the node definition holds someone else's
  sample file there. Do not substitute anything of your own.

### Dynamic capabilities

`get_model_schema` returns dynamic `capabilities.rules` alongside the static
`inputSchema`. Evaluate the rules before building `input`; `generate` validates the
same contract before spending credits.

A rule applies when every condition in `when` matches. Its effects may:

- disable or require a field;
- restrict `allowedValues` (active restrictions are intersected);
- provide a `defaultValue` when the field is omitted;
- set a `forcedValue`, which should be omitted from `input`.

For file conditions, `connected`/`any_connected` means a non-empty file value was
supplied; `not_connected`/`none_connected` means it was not. `enabled` means the
field at `path` is not disabled by another active rule: a checkbox switched off for
the chosen model neither applies its checked nor its unchecked effects. If `rules` is empty,
follow `inputSchema` alone. Only submit fields present in `inputSchema`; do not invent
product-UI preset selectors.

## Files

A file input takes a public `http(s)` URL or the `storage_object_id` of a file already in
the person's storage. Which tool puts it there depends on **who actually holds the file**.

### The file goes up as it is

**Never re-encode, resize, crop, or re-compress a file to make it fit a limit.** Not a
JPEG quality pass, not a scale-down, not a convert "just to be safe". The model generates
from what you send, the loss is silent and permanent, and the person sees only a worse
result they will blame on the model. If you already have a file that someone else
shrank, say so rather than passing it off as the original.

Size picks the channel, never the quality:

| Size | What to do |
|---|---|
| small enough for one call, bytes in hand | `upload_file` — base64 in a single call |
| too big for that, bytes still in hand | `start_upload` → `upload_chunk` → `finish_upload` |
| you can make an HTTP request yourself | `get_upload_url`, then POST the file |
| the file is the person's, not yours | `request_upload` — the upload panel |
| bigger than every route here | ask for a public `http(s)` URL and pass that straight into the input |

Do not carry these thresholds in your head: the server states them. `[FILE_TOO_LARGE]`
names the limit it just hit, `start_upload` answers with `chunk_max_bytes` and
`max_total_bytes`, `get_upload_url` and `request_upload` with `max_bytes`. A file that
does not fit the route you took moves to the next route down this table — it does not get
smaller.

### You hold it

A path you can read, bytes you produced yourself:

```
upload_file(filename, mime_type, data_base64)  →  {storage_object_id}
```

Small files only — the base64 passes through your context and is a third larger than the
file. Above the server's inline limit you get `[FILE_TOO_LARGE; retryable=false]`, and
there are two ways on.

**In pieces, over MCP.** Same bytes, same storage, just split across calls:

```
start_upload(filename, mime_type, total_bytes?)  →  {session_id, chunk_max_bytes}
upload_chunk(session_id, index: 0, data_base64)  →  {next_index: 1, ...}
upload_chunk(session_id, index: 1, data_base64)  →  {next_index: 2, ...}
finish_upload(session_id)                        →  {storage_object_id}
```

Cut the **file** into pieces of at most `chunk_max_bytes` and base64 each piece on its
own. Slicing the base64 text of the whole file instead produces a broken file and nothing
will say so. Indexes run from `0` with no gap; sending the same index again replaces that
piece, so a chunk that failed is simply repeated. Nothing is stored until `finish_upload`
returns the `storage_object_id`, and the assembly is dropped after
`expires_in_seconds` without a new piece.

Errors say what to do next: `[INCOMPLETE_UPLOAD]` — a piece is missing, send it and
finish again; `[FILE_TOO_LARGE]` — cut smaller, or take the route below;
`[TOO_MANY_UPLOADS]` — finish or drop an assembly you already have open;
`[UNKNOWN_UPLOAD_SESSION]` — the assembly is gone, start over with `start_upload`;
`[UPLOAD_IN_FLIGHT]` — a `finish_upload` for this session is still running, wait for its
answer instead of calling again. A `finish_upload` that fails on the storage side keeps
the pieces: call it again with the same `session_id` rather than re-sending the file.

**Or outside MCP**, which does not spend your context at all — use `get_upload_url` and
POST the file yourself:

```
get_upload_url  →  {upload_url, method: POST, content_type: multipart/form-data,
                    file_field: "file", ticket, expires_in_seconds, max_bytes,
                    result_field: "storage_object_id"}
```

The `ticket` authorizes the upload on its own, so no header and no key of yours are
needed:

```
curl -F file=@<path> -F ticket=<ticket> <upload_url>   →  {"storage_object_id": …}
```

It is good for `expires_in_seconds` (minutes, not hours) and works only on `upload_url`;
fetch a fresh one when it runs out. Do not print it in your answer. On a server with no
pass to give, `ticket` is null and `auth` says to send the same `Authorization` header you
use for MCP — that route needs a key you can actually read.

### The person holds it

A picture they dropped into the chat, something on their desk, anything you were only
told about. **You cannot forward that file.** A chat attachment reaches you as pixels in
your context, not as bytes you can pass on, and base64 you write from looking at an image
is a broken file, not a copy of it. Do not try.

```
request_upload()  →  {status: "waiting_for_the_person", upload_url, ticket, max_bytes}
```

This opens an upload panel in the conversation: the person picks the file there and it
goes straight into their storage. Say the panel is open and wait — nothing is uploaded
yet, and `generate` has nothing to work with until it is.

Then wait. Where the client allows it, the panel speaks for itself: the
`storage_object_id` arrives as a message from the person, and there is nothing left to
call — use the number as the file input. Where it does not, the panel shows them a short
code instead and they send that:

```
claim_upload(code)  →  {storage_object_id, filename, mime_type, bytes}
```

Never assume the file arrived because the panel looks busy or the person says they picked
one. Until one of those two things happens, you have no id — and an id you guessed is
someone else's file.

`ticket` is not for you: it is the panel's one-purpose pass. Never print it, never put it
in a link, never send it anywhere except `upload_url`.

### Already public

If the file has a public `http(s)` URL, upload nothing — pass the URL straight into the
input.

## Waiting for the result

`generate` returns immediately — the job is asynchronous. `get_job` statuses:

| Status | Meaning | What to do |
|---|---|---|
| `queued` | in the queue; `position` is the place in it | wait |
| `running` | being generated | wait |
| `completed` | done; `result` = `{output_name: [links] or a plain value}` | pass links on as they are; a text value is the answer itself — give it to the user |
| `failed` | failed; `error.code` + `error.message` | see below |
| `canceled` | canceled | report it |

**Where the client shows the generation panel** (Claude, ChatGPT, Cursor, VS Code and
other MCP Apps hosts), `generate` opens it under your call. The panel checks the status
itself, shows the queue, progress and result to the person, and posts one message when
the job finishes. Do not call `get_job` then and do not write progress updates — wait
for that message, then reply once with the result, its links and a short summary. The
panel also puts the `job_id` into your context, so you can still cancel the job.
Tell the person in one line that the panel is following the job. The panel can be
closed at any time, and then no message comes: you cannot notice that yourself, so
say that if nothing arrives they only need to ask you again.

**Everywhere else** (Codex, Claude Code and other clients without the panel), call
`get_job` with `waitSeconds=25`. The server waits for the job and answers the moment it
finishes; while it is still `queued` or `running`, call again right away — no sleep in
between. One message with the result at the end, no progress messages on the way. If
the person asks again after the panel was shown, fall back to the same.

`averageTimeInSeconds` from the schema is a worst-case ceiling, not a time to wait.
Budget roughly 3–5 × `averageTimeInSeconds` for waiting. Past that, do not stay silent:
tell the user where the job stands (`status`, `position`) and ask whether to keep waiting
or cancel. Never lose the `job_id` — it still reaches the job in a later session.

The keys in `result` are the `outputs[].name` values from the schema, so you know them
in advance.

## Result media

When a job finishes with a media output, `get_job` returns three things at once:

- **the JSON** — `result` as before plus `outputs`:
  `[{name, type, url, mime, expires_at}]`, one entry per file;
- **`resource_link` blocks** — one per file, with a `mimeType` and the address. A client
  that can show attachments renders them on its own;
- **a text hint** carrying a ready-made markdown line for images.

On top of that the server ships a result viewer app (the MCP Apps extension): a client
that negotiated it shows the image with an `img` tag and video or audio in a player, right
in the chat. There you need to do nothing — the result is already visible. The tool result
cannot tell you whether that happened, so behave as if there is no app.

Files never pass through the server in any of these paths: the link is signed by the
backend and the client fetches it directly. So you have no bytes — you have an address and
a type.

**How to show an image in the chat:** put it into your own reply as markdown —
`![name](url)`. The ready-made line is already in the hint from `get_job`, so copying it
is enough. What renders the image is your message, not the tool result.

**Never claim you displayed a file when you did not.** Not "as a card", not "as a widget",
not "the image above": if all you passed was a link, a link is what the user sees. Do not
explain what they are looking at with rendering that does not exist.

Video, audio, and 3D cannot be embedded as markdown — for those, the `resource_link` and a
plain link are all there is. Say exactly that: it can be viewed at the link.

The links are signed and time-limited — the exact moment is in `expires_at` (in the URL
itself it is `Expires=<unix-time>`). If the file is needed
for anything lasting, download it now rather than "later, from this link".

## Tool errors

They arrive as text. Read the text rather than reacting to the mere fact of an error:
half of them are not retryable, and a blind retry either repeats the same error or
spends the credits twice.

| Text | Cause | Action |
|---|---|---|
| `Unknown model: X. Call list_models…` | slug/id is not in the MCP catalog | call `list_models`, take the `id` from there |
| `Model is not enabled for MCP: X` | the model is REST-only | do not retry, tell the user |
| `Input does not match the model schema — field: message; …` | input failed the server's validation | fix **the listed** fields against `inputSchema` and active `capabilities`; no credits were spent — the request never reached the backend |
| `[MODEL_UNAVAILABLE; retryable=false]` | the model is temporarily unavailable through MCP | do not retry; use another model or report that this one is currently unavailable |
| `This tool runs on your own account: send your … API key…` | the client sent no token | ask the user to put their Spectra key in the client config (`Authorization: Bearer <key>`); the catalog keeps working meanwhile |
| `Your authorization does not cover this action. Missing scope: …` | there is a token, but not that scope | re-authorize with the required scope; retrying with the same token is pointless |
| `Job id must be a number: X` | the `job_id` got mangled | pass the value from the `generate` response verbatim |
| `… is unreachable: …` | network or timeout to the backend | `list_models`, `get_model_schema`, `get_job` — retry with backoff. `generate` — **do not retry blindly**: the job may have been created, and a second start spends credits again. MCP has no job list — ask the user before starting it again |

Backend errors (out of credits, revoked key, rate limit, 5xx) are passed through with the
backend's own text. Read the message and relay what it means: "out of credits" and "no
access to this model" are fixed by the user, not by calling again.

## When a job fails (`status: "failed"`)

The response carries `error.code` — the backend's raw status (it starts with `error`) —
and `error.message`, the reason.

1. **Read `message`.** If the cause is the input (format, size, content, unsupported
   value), it is not a transient glitch: fix the input against the schema.
2. **Retry only for a clearly transient cause** (timeout, provider-side internal error),
   and **only once**. A repeat `generate` is a new job and a new charge, so it is better
   to ask the user first.
3. **Two failures in a row for the same reason — stop.** Show the user `error.code`,
   `error.message`, and the `job_id`; the call is theirs from there.
4. Do not invent a cause when `message` is empty: say exactly that — the job failed
   without an explanation, and here is its `job_id`.

Cancel a job stuck in `queued`/`running` with `cancel_job` — the credits spent on it are
returned. `cancel_job` on an already finished job is pointless.

## What not to do

- Call `generate` unprompted, in batches, or "to see what comes out".
- Repeat `generate` after an error without changing the input.
- Invent parameter names, enum values, or a `storage_object_id`.
- Carry one model's schema over to another, including a neighbouring version.
- Wait past a sensible budget in silence instead of reporting back to the user.
- Post a status message on every poll, or call `get_job` while the generation panel is
  following the job.

## Connecting (for reference)

The key comes from @spectraapi_bot together with the subscription.

```json
{
  "mcpServers": {
    "spectra": {
      "type": "http",
      "url": "https://dev.phygital.plus/api/mcp",
      "headers": { "Authorization": "Bearer <your Spectra key>" }
    }
  }
}
```

Full setup guide: https://spectraapi.one/doc/#mcp
