* 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>
11 KiB
MCP conventions
Install and register
From the repository root, with the source-build prerequisites installed, install the CLI independently of the build directory:
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):
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:
{"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.
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:
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. 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; acceptsfilterandenabled_only. Returns a bare JSON array of{id, label, menu, shortcut, params, enabled, disabledReason, needsDocument}objects (see Command list replies).command_run:id, optionalparamsandwait. Command parameters remain engine-defined.command_batch: orderedsteps, optionalstop_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 returnsdocument:null. Every layer (including groups and nested children) reports raster-onlyhasMask,hasVectorMaskfor vector mask presence, andvectorMaskEnabled(true/falsewhen present,nullwhen absent). A disabled vector mask still hashasVectorMask:true.render_preview: bounded PNG content;indexandmax_side(default 1024, maximum 2048).ui_inspectandui_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
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 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.
{"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.