capture-one-mcp
capture-one-mcp
An MCP server that drives Capture One Pro on macOS through its AppleScript/JXA scripting interface — sessions and catalogs, collections, variants, adjustments, layers, recipes and output.
Status: complete through Phase 5 — 42 tools. Reading and navigation, ratings and metadata, the full adjustment surface, layers and masks including AI people masking, and output: recipes, processing with queue polling, import and batch rename, plus a non-destructive candidate/preview/compare workflow. Tethered capture is deliberately out of scope — see docs/TOOL-SURFACE.md.
Findings from verifying against the live app: adjustments · layers and masks · output, import and rename
Target
Built against the dictionary of the installed app:
App | Capture One 16.8.5.30 |
Bundle id |
|
Dictionary | |
Digest |
Regenerate the digest against whatever build is installed:
python3 scripts/gen-dictionary.py > docs/DICTIONARY.mdInstall
npm install && npm run buildRegister it with an MCP client, e.g. Claude Code:
claude mcp add capture-one -- node /absolute/path/to/capture-one-mcp/dist/index.jsVerify it end to end against whatever Capture One has open:
npm test # build + mocked regression tests; never controls Capture One
npm run verify # typecheck + validate every embedded JXA block parses
npm run verify:live # resolve every generated property name against the running app
npm run smoke # drive the built server over stdio as a real MCP clientverify:live needs Capture One running with a document open. It exists because
a wrong JXA property name fails as "Can't convert types.", which this
codebase's defensive wrappers turn into a silent null — see
docs/OUTPUT.md.
Tools
These tools are always available; none of them modifies image data.
Tool | What it does |
| Running state, whether Apple Events are actually getting through, version/tier, open document. Call this first if anything times out. |
| Open sessions and catalogs, and which is frontmost |
| Paths, session folders, naming tokens, counters, queue depth |
| Albums, projects, groups, smart albums, folders, favorites |
| Focus a collection in the browser |
| Find images — filter by rating, color tag, pick, filename, extension; paginated |
| Full detail for one variant: EXIF, adjustments, crop, metadata, keywords, layers, outputs |
| Output recipes and their full settings |
| Available style and preset names |
| Keywords in the current document |
| The 83 adjustment properties with types and measured ranges |
| Layers on variants, with luma-range settings and all registered scalar adjustments |
| The 40 writable recipe properties |
| Depth of the processing queue |
| Files previously written, and whether they still exist |
| Readable differences between a source variant and a candidate clone |
| Change the Capture One selection (view state only) |
co_open_document is in the write tier rather than the read tier: opening an
older document can trigger an irreversible format migration, and it changes
which document later variant ids resolve against.
co_get_variant reads all registered scalar adjustments on the variant and its
layers; co_list_layers reads those same settings on each layer. Adjustment keys
remain camelCase (for example, levelMidtoneRgb); map these through
co_list_adjustments to the snake_case parameters accepted by the write tools.
Unavailable values are null, with details in the adjacent adjustmentReadErrors
object. Check these errors before treating a snapshot as complete, and never
restore null values. These reads cover the scalar registry, not masks or structured
adjustments such as curves/color-editor settings; they cannot establish full style
or image equivalence. Reads are sequential, so avoid editing during a snapshot.
Editing tools, hidden unless CAPTURE_ONE_MCP_ALLOW_WRITE=1:
Tool | What it does |
| Rating (0–5), color tag, pick flag |
| IPTC fields — headline, description, creator, copyright, rights, title, instructions, city/state/country |
| Apply an existing keyword (see limitation below) |
| Remove a keyword from variants (unverified — see below) |
| Any of the 83 adjustment properties, range-validated and read back |
| The same, applied to a single layer |
| Create adjustment layers |
| Name, enabled, opacity |
| Invert, fill, feather, refine — not verifiable, see below |
| Copy a mask between layers |
| Layer mask luma range |
| Apply a named style to a layer |
| AI people masking |
| Recipe settings, validated and read back |
| Render to disk, polling the queue and reporting the files written |
| Copy originals out |
| Import image files (paths validated) |
| Clone variants into editable candidates, preserving existing edits |
| Render one variant to a bounded sRGB JPEG returned as an MCP image |
| Open a session or catalog by path |
co_process_variants checks the effective recipe before submitting work. Recipes
with existing_files: overwrite require CAPTURE_ONE_MCP_ALLOW_DESTRUCTIVE=1
as well, regardless of destination: custom paths and folder aliases can also
reach originals. add suffix and skip recipes need ordinary write access only.
Unreadable or unknown existing-files behavior is refused. When the recipe is
omitted, the current recipe is resolved and explicitly submitted so another
enabled recipe cannot bypass the check.
Requiring CAPTURE_ONE_MCP_ALLOW_DESTRUCTIVE=1 as well:
Tool | What it does |
| Clear, fill or rasterize a mask — not undoable |
| Delete a layer (never the background layer) |
| Rename original files on disk — not undoable |
Editing tools never apply to the whole document implicitly: each requires either
explicit variant_ids or use_current_selection: true, reports every id it
could not resolve rather than silently doing less, and returns exactly what it
changed.
Keywords cannot be created via AppleScript
Capture One 16.8.5 exposes no way to create a keyword. The keyword class
declares no make responder, all three of its properties are read-only, and
apply keyword documents its parameter as "an existing keyword object". Every
construction form was tried against the live app — make at the document, at a
variant, at a keywords element, with properties and with data — and all fail
with "You cannot set or change this property of this object" or "AppleEvent
handler failed". Keywords must be added in the Capture One UI, or imported into
a keyword library from a file.
Consequently co_remove_keyword is implemented but has not been verified
against a real keyword — the standing test session has none and none can be
created, so its blast radius (whether deleting a variant's keyword also removes
it from the document) is unconfirmed. The tool reports
documentKeywordSurvives so a caller can check.
Candidate editing workflow
Non-destructive trial edits: clone a variant, edit the clone, look at it, and keep whichever one you prefer. Nothing is overwritten and nothing is deleted.
Step | Tool |
Clone the originals |
|
Edit the candidates |
|
Look at the result |
|
See what changed |
|
Choose | Keep the candidate id, or go back to the source id. Both variants remain. |
All three require the expected document_id and re-check it, so they stay safe
even with document binding off. co_create_candidates is additive and not
idempotent: an incomplete result must be inspected before any retry, or you get
duplicate copies. co_render_preview needs write access — it creates a private
temporary recipe and submits processing — though it never changes image
adjustments.
Comparison is deliberately conservative. It covers the scalar adjustment registry, crop, lens correction, flags, metadata, engine, processing mode, style names and keywords; it cannot see masks, curves, luma ranges or colour-editor structures, and layers are paired by position because they have no id. No differences does not mean identical edits or identical appearance, and the tool says so in its own result.
See the workflow guide, candidate details and preview limits. The workflow passed automated and live tests on Capture One Pro 16.8.5 using five copied RAWs — validation evidence and limits.
Testing
All live testing runs against one disposable session that you nominate, and
client sessions and catalogs are never touched. The harnesses will not choose a
document on their own: set CAPTURE_ONE_MCP_TESTBED_DOCUMENT to the document id
of a session you are willing to have modified, and both scripts/testbed.mjs and
scripts/verify-adjustments.mjs refuse to run against anything else — including
refusing outright when the variable is unset.
export CAPTURE_ONE_MCP_TESTBED_DOCUMENT="/path/to/a scratch session"The reference figures below come from a 138-variant session (69 RAW + 69 JPEG).
Because Phase 2 writes to real variant state, scripts/testbed.mjs records and
restores it. It talks to Capture One directly rather than through the MCP
server, so it keeps working when the server is read-only and cannot be broken by
the code under test, and it is hard-locked to that one session path — it
refuses to run against any other document rather than trusting the caller.
node scripts/testbed.mjs snapshot # ratings, tags, picks, IPTC, keywords, selection,
# all 83 adjustments, layers, and all 9 recipes
node scripts/testbed.mjs diff # what has changed since
node scripts/testbed.mjs restore # put it all back, then re-verifySnapshots land in .testbed/ (gitignored — it contains real image metadata).
Configuration
Variable | Default | Effect |
| unset | Enables the editing tools. Read-only until set. |
| unset | Additionally enables irreversible verbs. Requires the above. |
|
| Cap on variants returned in one call |
|
| Fallback Apple Event budget for calls that set none |
| unset |
|
Binding to a document
Variant ids are small per-document integers (641, 3480), and nothing in the
scripting interface ties an id to the document it came from. If the frontmost
document changes between two calls — clicking another Capture One window is
enough — an id read from one document can resolve to a completely different
variant in another, and a write lands there silently.
Three modes:
| Behaviour |
unset | No check. Only safe when exactly one document is ever open. |
| Recommended. Binds to the first document the server actually touches, then refuses any other for the life of the process. |
a document id | Binds to that specific document, whatever is open. |
auto needs no path up front and follows whatever you are working on, while
still converting "silently edited the wrong catalog" into a refusal. The binding
lasts for the life of the process, so reconnect the server when you switch to a
different session or catalog — in Claude Code, /mcp → reconnect.
A document id is the document's own folder path, e.g.
/Volumes/Photos/a session folder. co_status reports the current
binding and warns when it does not match the front document. Note that
co_status itself does not bind — a health check should not claim the server.
Tools above their permitted risk level are not registered at all, so a blocked tool is invisible to the model rather than present-and-failing.
Requirements
macOS with Capture One installed and running
Node 20+
Automation permission: the process hosting this server must be allowed to control Capture One (System Settings → Privacy & Security → Automation)
Some verbs are gated by Capture One's licence tier — the dictionary marks
process as PRO only and autocrop as Cultural Heritage only.
Notes on the Apple Event layer
Two behaviours worth knowing, both verified against the installed app rather than assumed:
Application("Capture One").version()and.running()are answered by JXA from the app bundle without sending an Apple Event. They stay responsive even when the event pipe is completely blocked, so they are useless as a liveness check.app versionis served by Capture One's own handler and is the honest probe — that is whatco_statususes.A pending macOS automation-consent dialog blocks every Apple Event on the machine until someone clicks it. Nothing fails fast; calls just sit until the Apple Event timeout (
-1712).src/jxa/bridge.tsimposes its own wall-clock budget and translates this into an actionable message.
Notes on the variant model
Verified against a real 138-variant session (69 NEF + 69 JPG), not assumed:
variant.namehas no file extension and is not unique. A RAW+JPEG pair produces two variants both namedDSC_1950. Extension is what makes a row identifiable, soco_list_variantsalways returns it. An early version filtered on a suffix ofnameand matched nothing at all.variant.positionis the position within a clone stack, not an index in the collection -- it is1for every unstacked variant. Exposed aspositionInStackso it cannot be mistaken for a browser index.document.pathis the folder containing the document, not the document itself.co_get_documentalso returnsdocumentPathandfolder.image.dimensionsis a point record{x, y}, not a string; it stringifies to[object Object]if treated as one. Returned as{width, height}.
Why bulk reads
Each property access is one Apple Event, so the access pattern dominates everything. Measured on the 138-variant session:
Access pattern | Cost |
| 24ms for all 138 |
fetch images then | 2313ms for the same data |
per-variant hydration | ~215ms each |
So the filter pass reads whole-specifier properties, chaining through
relationships where needed, and stays flat regardless of collection size.
Page hydration uses the same trick while the scope is modest and falls back to
per-variant reads past BULK_HYDRATE_MAX (2000) or when the scope is a plain
array (scope: "selection").
End-to-end result on that session:
Call | Time |
all 138 rows, no extra fields | 280ms |
20 rows + EXIF | 585ms (was 4295ms) |
all 138 rows + EXIF, crop, adjustments, metadata | 3968ms |
Layout
src/index.ts stdio entry point
src/server.ts tool registration + risk gating
src/config.ts risk levels and the env-var gate
src/jxa/bridge.ts osascript transport, timeouts, error translation
src/jxa/health.ts liveness probe that separates "not running" from "blocked"
src/co/ domain layer:
documents, collections, variants — reading and navigation
edit, adjust, layers, process — writing
candidates, preview, comparison — candidate workflow
*-properties.ts, *-ranges.ts — GENERATED registries
scripts/ gen-dictionary.py, gen-adjustments.py, gen-recipes.py,
gen-adjustment-ranges.mjs — generators
check-jxa.mjs, verify-property-names.mjs, smoke.mjs
— guards and smoke test
testbed.mjs, verify-adjustments.mjs — live test harnesses
docs/ DICTIONARY.md (generated digest), ADJUSTMENTS.md, LAYERS.md,
OUTPUT.md, CANDIDATES.md, PREVIEWS.md,
EDIT-PREVIEW-WORKFLOW.md, WORKFLOW-VALIDATION.md,
TOOL-SURFACE.md (historical design doc)
reference/ vendored .sdef from the installed app
CLAUDE.md guidance for agents working on this repo (AGENTS.md points to it)Licence
MIT