Files
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

11 KiB
Raw Permalink Blame History

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; accepts filter and enabled_only. Returns a bare JSON array of {id, label, menu, shortcut, params, enabled, disabledReason, needsDocument} objects (see 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 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.