Files
photocraft/docs/mcp.md
T
274f6d3e26 MCP bridge: waited commands poll the app's job, with progress and cancel (#8130) (#8134)
* MCP headless: waited commands run as jobs, with progress and cancel (#8129)

Headless command_run and command_batch steps that wait (the default) ran
job-capable commands inline while holding the session lock. Cancelling a
long Gaussian Blur did not stop it: it still added its history step, and
doc_inspect and jobs_list queued behind it. No progress was reported.

A waited job-capable command now starts as a background job
(Headless::command_begin) and the server polls it with the lock released
(Headless::job_poll), so other calls are served meanwhile. With a progress
token it sends notifications/progress (strictly increasing, at most ten a
second; for a batch, finished steps plus the current step's fraction).
notifications/cancelled cancels the job and leaves the document unchanged;
a drop guard does the same when rmcp drops the request future. Headless
keeps the outcome of each waited job as sync_jobs sees it end, since
jobs.list keeps only the last few. Both backends share one batch loop,
which runs no further step once the request is cancelled.

Tool hints: jobs_list is read-only, doc_select is non-destructive and
idempotent, and the scoped exports (which refuse to overwrite) are
non-destructive. doc_save's description lost a run of spaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP headless: cancel a waited job whose request was dropped before it started

The Abandon guard was created only after command_begin returned. A request
dropped while the start still waited for the session lock left the job it
then started running, and its `waited` entry behind. The guard is now armed
inside the locked closure that starts the job, so dropping the unread output
cancels it. Review feedback on #8132.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP headless: job_poll applies finished jobs before checking the job

A caller polling Headless::job_poll directly, without sync_jobs in between,
kept seeing Running after the worker finished, because the session only
drops a finished job from its running list when its jobs are polled.
job_poll now syncs first. The MCP path already synced in headless_op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP headless: a cancel that loses the race returns the job's result

job_abandon ignored whether Session::cancel_job cancelled anything and
always dropped the stored outcome, so headless_wait answered `Cancelled`
for a job another request had already applied to the document. It now
returns how the job really ended: Cancelled only when it cancelled the
job, the job's result when it had already ended, and an error when the
job cannot be cancelled (printing) and is still running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP headless: waited commands run as jobs, with progress and cancel (#8129)

Headless command_run and command_batch steps that wait (the default) ran
job-capable commands inline while holding the session lock. Cancelling a
long Gaussian Blur did not stop it: it still added its history step, and
doc_inspect and jobs_list queued behind it. No progress was reported.

A waited job-capable command now starts as a background job
(Headless::command_begin) and the server polls it with the lock released
(Headless::job_poll), so other calls are served meanwhile. With a progress
token it sends notifications/progress (strictly increasing, at most ten a
second; for a batch, finished steps plus the current step's fraction).
notifications/cancelled cancels the job and leaves the document unchanged;
a drop guard does the same when rmcp drops the request future. Headless
keeps the outcome of each waited job as sync_jobs sees it end, since
jobs.list keeps only the last few. Both backends share one batch loop,
which runs no further step once the request is cancelled.

Tool hints: jobs_list is read-only, doc_select is non-destructive and
idempotent, and the scoped exports (which refuse to overwrite) are
non-destructive. doc_save's description lost a run of spaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP headless: cancel a waited job whose request was dropped before it started

The Abandon guard was created only after command_begin returned. A request
dropped while the start still waited for the session lock left the job it
then started running, and its `waited` entry behind. The guard is now armed
inside the locked closure that starts the job, so dropping the unread output
cancels it. Review feedback on #8132.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP bridge: waited commands poll the app's job, with progress and cancel (#8130)

A waited command_run or command_batch step of an engine command now starts it
with engine.execute {wait: false} (sent once) and, when the app runs it as a
background job, polls jobs.list until it ends. A job can run past the bridge's
55s reply timeout, its progress is forwarded, and cancelling or dropping the
request sends jobs.cancel. UI command ids keep wait: true. A failed poll or a
job no longer listed is reported with its id rather than guessed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP bridge: keep the cancel guard armed until jobs.cancel returns; clarify the docs

The cancel branch disarmed the Abandon guard before awaiting jobs.cancel, so a
request future dropped during that await sent no cancel. The control-protocol
table now says polling applies only to a waited command_run. Review feedback
on #8134.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP headless: job_poll applies finished jobs before checking the job

A caller polling Headless::job_poll directly, without sync_jobs in between,
kept seeing Running after the worker finished, because the session only
drops a finished job from its running list when its jobs are polled.
job_poll now syncs first. The MCP path already synced in headless_op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP headless: a cancel that loses the race returns the job's result

job_abandon ignored whether Session::cancel_job cancelled anything and
always dropped the stored outcome, so headless_wait answered `Cancelled`
for a job another request had already applied to the document. It now
returns how the job really ended: Cancelled only when it cancelled the
job, the job's result when it had already ended, and an error when the
job cannot be cancelled (printing) and is still running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP bridge: a refused jobs.cancel is not reported as a cancellation

bridge_wait discarded the app's reply to jobs.cancel and always answered
`Cancelled`. The app refuses to cancel a job that is no longer running,
so a job that finished between the jobs.list poll and the cancel was
reported as cancelled although its edit may be applied. A refused cancel
now answers that the job was not cancelled and points to jobs_list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP bridge: a cancel during a failed poll still cancels the job

When notifications/cancelled arrived while a jobs.list poll was in
flight, and the poll and its retry then failed, bridge_wait disarmed the
Abandon guard and returned the poll error without checking the waiter,
so no jobs.cancel reached the app and the edit ran on after the caller
cancelled it. That error path now sends jobs.cancel first when the
request was cancelled. A failed poll without a cancel still leaves the
job running and names it.

Reported in review of #8134.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MCP bridge: a cancel doesn't wait for a slow jobs.list poll

bridge_wait checked for a cancel only between polls, so a cancel that
arrived while the app was slow to answer jobs.list waited for the poll,
up to the 55s call timeout, before jobs.cancel was sent. The poll now
races the cancel.

That needs BridgeClient to drop the connection of a call abandoned
before its reply: it kept the one cached connection, so jobs.cancel
queued behind the unanswered poll on the same socket (the app serves a
connection's requests in order), and a half-sent request could share it.
A call now takes the connection out of the cache and returns it only
once answered, as a timeout or lost reply already dropped it.

The poll-error check from the previous commit stays for a cancel that
races a failing poll; its test is replaced by one for the slow poll,
which covers the reviewed case. A BridgeClient test checks that a
dropped call's connection is not reused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(automation): retain cleanup after failed bridge cancellation

* chore: restart CI for PR #8134

* test(automation): classify the smart object replacement MCP tool

* test(automation): synchronize dropped bridge waits and accept reconnections

* Fix CI after merging upstream

* Fix CI after merging upstream

* Deduplicate Arabic closure messages after upstream merge

* fix(engine): bound active and cancelling background workers

* Fix cancellation and draining in bridge merge

* Test release-aware draining cleanup

* Fix draining regression warning

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Khawar Zamman Wani <wanikhawar@Khawars-MacBook-Air.local>
Co-authored-by: FredApps <fredapps@users.noreply.github.com>
2026-11-05 13:35:13 +01:00

181 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MCP conventions
## Install and register
From the repository root, with the [source-build prerequisites](development.md#prerequisites)
installed, install the CLI independently of the build directory:
```sh
cargo install --path apps/photocraft-cli --locked
# POSIX shells, for Cargo's default install location:
export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH"
photocraft-cli --help
photocraft-cli mcp --help
```
Cargo installs a binary, not a symlink into `target/`, so it survives `cargo clean`.
Re-run the install command after updating the checkout.
By default the executable goes in `$CARGO_HOME/bin` (`~/.cargo/bin` when unset), or
`%USERPROFILE%\.cargo\bin` on Windows. If you set `--root`, `CARGO_INSTALL_ROOT`, or
Cargo's `install.root`, use that installation root's `bin` directory instead. Add it
to the `PATH` inherited by the MCP client, not just an interactive shell, and restart
the client after changing its environment.
Register the installed command explicitly (replace `/work/project` with an existing folder):
```sh
claude mcp add photocraft -- photocraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project
```
Other MCP clients can use this bare-name configuration:
```json
{"mcpServers":{"photocraft":{"command":"photocraft-cli","args":["mcp","--automation-read-root","/work/project","--automation-write-root","/work/project"]}}}
```
Linux `.deb` and `.rpm` packages already install `/usr/bin/photocraft-cli`; those users
can skip the source install. Other release locations are listed in the
[installed-release guide](../book/src/automation/mcp.md#from-an-installed-release).
Installing the CLI does not start or register an MCP server automatically.
If a client reports `ENOENT` or "Executable not found in $PATH", verify the install with
`command -v photocraft-cli` (PowerShell: `Get-Command photocraft-cli`) in the client's
environment. If that environment cannot be changed, set JSON `command` to the absolute
installed binary path, not a literal `~` or `$HOME` expression. Avoid symlinks into
`target/release`: cleaning the build directory breaks them. A source build alone does
not put the CLI on `PATH`.
## Start a session
PhotoCraft follows the same conventions as FilmCraft #28. Start a headless session:
```sh
photocraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project
```
Paths are relative to the corresponding root, or absolute paths beneath it; without a root that
filesystem authority is absent. Bridge mode uses `--bridge` and `--control-token-file` as
described in [development.md](development.md). UI tools require a running desktop app.
A client has the full rights of the roots it is given: it can read any file below the read
root, and `doc_save`, `doc_export` and the export tools can create files anywhere below the
write root. `doc_save` and `doc_export` replace an existing file only when its extension is an
image or document format PhotoCraft writes (PNG, JPEG, TIFF, PSD, PSB, .pcraft, OpenRaster,
PDF, …) or its bytes are an image; anything else (`src/main.rs`, `.env`) is refused and left
untouched. The multi-file exports (layers, data sets) never replace a file. An agent reads
text from documents (layer names, metadata) that may try to steer it, so prefer a dedicated
output folder as the write root over a whole project.
Use absolute root paths in MCP client configurations, since a relative root is resolved from
the client's launch directory. In bridge mode, configure roots on the **desktop app**; root
flags on `photocraft-cli mcp --bridge` are rejected. Explicit `--control-token` and
`--control-token-file` flags require `--bridge` (or `PHOTOCRAFT_CONTROL_PORT`) and are mutually
exclusive. Bridge credentials can also come from `PHOTOCRAFT_CONTROL_TOKEN` or `PHOTOCRAFT_CONTROL_TOKEN_FILE`.
MCP and `serve` launch options (all but `--help`/`-h`) take one non-empty value each and cannot
be repeated. Missing values, stray positional arguments and options for the wrong mode are
usage errors (exit 2), reported on stderr before the server starts; stdout stays available for
protocol messages. For `serve`, explicit credential flags require `--port` (or
`PHOTOCRAFT_CONTROL_PORT`); stdio needs no control token.
## Tools and resources
- `command_list`: discover engine ids, parameter docs and current enabled state; accepts
`filter` and `enabled_only`. Returns a bare JSON array of
`{id, label, menu, shortcut, params, enabled, disabledReason, needsDocument}` objects (see
[Command list replies](control-protocol.md#command-list-replies)).
- `command_run`: `id`, optional `params` and `wait`. Command parameters remain engine-defined.
- `command_batch`: ordered `steps`, optional `stop_on_error` (default true); stops at the first
error unless false. Not an atomic transaction: earlier successful steps stay applied, with
no rollback. Existing per-step results and reply budgets are preserved.
- `doc_inspect`: layer tree, history and selection; an empty session returns `document:null`.
Every layer (including groups and nested children) reports raster-only `hasMask`,
`hasVectorMask` for vector mask presence, and `vectorMaskEnabled` (`true`/`false` when
present, `null` when absent). A disabled vector mask still has `hasVectorMask:true`.
- `render_preview`: bounded PNG content; `index` and `max_side` (default 1024, maximum 2048).
- `ui_inspect` and `ui_screenshot`: live app state/window in bridge mode.
Existing document/session/job/UI tools remain listed, including the documented
`doc_render_preview` spelling. There are no hidden legacy aliases. Every tool has a title
and all four annotation hints. Reads (`command_list`, `doc_inspect`, `session_list`, the
previews, `ui_inspect`, `ui_screenshot`, `jobs_list`) are read-only and idempotent. `doc_new`,
`doc_open`, `doc_place`, `doc_select` and the scoped exports (`doc_export_layers`,
`doc_export_data_sets`, which refuse to overwrite) are not destructive; `doc_select` is also
idempotent. Generic command/control tools conservatively advertise writes; file saves may
replace existing image and document files. Hints do not grant permission.
After a failed batch, `completed` and per-step `results` report successful steps, not a
rolled-back transaction. Inspect the document and its history with `doc_inspect`; commands
do not necessarily add one history entry each. For undoable edits, use `command_run` with
`{"id":"edit.undo","params":{}}` once per undoable history entry added, inspecting history
as you recover. Undo does not roll back files written to disk. If a reply-budget error says
the current step may have completed, inspect its effects before retrying or undoing it.
Before experiments, use `command_run` with `image.duplicate` and `{}` params, or
`history.newDocument` and `{}` params (copies the current state by default). Both create a
new active document, leaving the source unchanged; run experimental edits on that copy.
The optional `state` parameter of `history.newDocument` selects a retained historical state.
Unknown tool arguments return JSON-RPC `-32602`, naming the key, before execution. Batch
step envelopes are strict too. Nested engine `params` retain their command-specific handling.
Tool execution failures return `isError:true`. A worker panic becomes a tool error and the
existing poisoned-session recovery keeps serving. Invalid JSON lines receive `-32700` with
`id:null`; the next line is still processed.
Embedded Smart Objects support `command_run` with `layer.smartObjects.editContents` and
`layer.smartObjects.convertToLayers`, optionally targeting `params.layer`. Use
`layer.smartObjects.saveContents` in an opened contents document to update its parent in memory;
save nested contents from the inside outward, then `doc_save` the parent to a scoped path.
`doc_save` on a contents document exports that document rather than updating its parent.
No filesystem root is needed for the in-memory commands. External linked sources that require
a file read remain refused. See the [control policy](control-protocol.md#engine-commands)
for the shared restrictions.
`photocraft://document` and `photocraft://commands` return live JSON matching `doc_inspect`
and `command_list`. Resource reads are uncached; tool/resource catalogs are private and cached
for ten minutes. List/read responses include the MCP 2026-07-28 result/cache fields.
## Progress and cancellation
`command_run` (and each `command_batch` step) that waits, the default, runs a job-capable
command (filters, Content-Aware Fill/Scale, Photomerge, …) as a background job and returns its
result when the job ends. Meanwhile the server serves other calls (`doc_inspect`, `jobs_list`,
…), and the running job shows in `jobs_list`. With `_meta.progressToken`, the call sends
`notifications/progress` with the job's progress (0–1, `total` 1; for a batch, finished steps
plus the current step's fraction, `total` the step count) and the job's `message`, increasing
strictly and at most ten per second. `notifications/cancelled`, or the client dropping the
request, cancels the job: the document stays unchanged and a cancelled batch runs no further
step. A job that already finished before the cancellation arrived stays applied. Commands that
are not jobs still run to the end.
Headless, the session runs the job itself (#8129). In bridge mode (#8130) the app runs it: the
server starts it with `engine.execute {wait: false}` (sent once, never resent) and polls
`jobs.list`, so a job may run past the bridge's 55s reply timeout. A command that is not a job,
a UI command id and a lost reply to the start still fail at that timeout; see
[MCP bridge](control-protocol.md#mcp-bridge) for the limits.
A direct headless `command_run` of `file.export.renderVideo` reports frame counts when
`_meta.progressToken` is present (a string or number). Notifications increase strictly,
are throttled to at most ten per second except completion, and include `total`. No token
means no progress notifications. Ping remains responsive while rendering.
```json
{"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"file.export.renderVideo","params":{"dir":"frames","format":"png"}},"_meta":{"progressToken":"export-20"}}}
{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":20}}
```
Create a document and timeline first (`doc_new`, then `timeline.create`). Export uses the
configured write root and creates `dir` beneath it. Filenames must be single components;
absolute paths, traversal and escaping symlinks are rejected. Existing target filenames
are refused, including symlinks, so a cancelled or failed job cannot replace an earlier export.
Only files actually created by this job are removed on cancellation, error or an escaped panic;
unrelated files and the destination directory remain. PNG sequences and animated GIF share
the engine renderer. The document and playhead are unchanged.
Cancellation suppresses the request response and stops at a frame boundary (the current
frame/encoding may finish first). Unknown request ids are ignored. Render Video stays a
synchronous command even with `wait:false`; other job-capable commands keep their existing
`jobs_list`/`jobs_cancel` behavior. Render Video as a `command_batch` step reports no
progress and cannot be cancelled. Nested engine actions and bridge Render Video remain denied by the existing
ambient-filesystem policy; the new scoped writer is available for direct headless commands.