mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
260 lines
15 KiB
Markdown
260 lines
15 KiB
Markdown
# MCP server
|
|
|
|
`designcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server that lets Claude and
|
|
other agents drive DesignCraft: create documents, lay out frames and text, apply styles, place images, and look at
|
|
the result as PNG images. It speaks newline-delimited JSON-RPC 2.0 on stdin/stdout (protocol `2025-06-18`, also
|
|
`2025-03-26` and `2024-11-05`); logs go to stderr. The implementation is the `designcraft-mcp` crate.
|
|
|
|
## Register it
|
|
|
|
### From source (development)
|
|
|
|
With Rust and Cargo installed, run this from the repository root:
|
|
|
|
```sh
|
|
cargo install --path apps/designcraft-cli --locked
|
|
```
|
|
|
|
Cargo copies the executable into its installation root's `bin` directory, normally
|
|
`~/.cargo/bin` (Windows `%USERPROFILE%\.cargo\bin`). If you set `CARGO_INSTALL_ROOT`,
|
|
Cargo's `install.root` configuration, or `CARGO_HOME`, use that installation's `bin`
|
|
directory instead. Unlike a symlink into `target/release`, this copy survives
|
|
`cargo clean`. Repeat the install command after source changes to update it.
|
|
|
|
Ensure that directory is on `PATH`. For a default install in a POSIX shell:
|
|
|
|
```sh
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
designcraft-cli --help
|
|
```
|
|
|
|
Persist the PATH setting in your shell configuration (on Windows, add the directory
|
|
to your user Path). The **MCP client process** must inherit that PATH too; restart
|
|
the client after changing it. A GUI-launched client may not read shell configuration.
|
|
If it still reports `ENOENT` or "executable not found", use the absolute path to the
|
|
installed binary in its registration instead of the bare name.
|
|
|
|
### Headless and connected registration
|
|
|
|
After installation, register the CLI by name:
|
|
|
|
```sh
|
|
# Headless: an in-process engine, no window
|
|
claude mcp add designcraft -- designcraft-cli mcp
|
|
|
|
# Drive the running desktop app (install/start the desktop app separately)
|
|
designcraft --control 7979
|
|
claude mcp add designcraft-app -- designcraft-cli mcp --connect 7979
|
|
```
|
|
|
|
Other MCP clients use the same command line, e.g. in a JSON config:
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"designcraft": {
|
|
"command": "designcraft-cli",
|
|
"args": ["mcp"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
If the client cannot find the CLI on PATH, substitute the absolute installed path
|
|
(not a path under `target/`), for example:
|
|
|
|
```sh
|
|
claude mcp add designcraft -- /absolute/install/root/bin/designcraft-cli mcp
|
|
```
|
|
|
|
### From an installed release
|
|
|
|
The release packages ship `designcraft-cli` alongside the desktop app, so no build is needed:
|
|
|
|
| Install | CLI |
|
|
|---|---|
|
|
| Windows (MSI) | `C:\Program Files\DesignCraft\designcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
|
|
| Linux (deb, rpm) | `/usr/bin/designcraft-cli` |
|
|
| macOS | the separate `designcraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
|
|
|
|
```sh
|
|
# Windows, default install folder
|
|
claude mcp add designcraft -- "C:\Program Files\DesignCraft\designcraft-cli.exe" mcp
|
|
# Linux, or macOS with the CLI unzipped onto PATH
|
|
claude mcp add designcraft -- designcraft-cli mcp
|
|
```
|
|
|
|
## Confining file access
|
|
|
|
```sh
|
|
designcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
|
|
```
|
|
|
|
```json
|
|
{"mcpServers": {"designcraft": {"command": "designcraft-cli",
|
|
"args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}}
|
|
```
|
|
|
|
The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client
|
|
config works across the suite) keep the agent to the folders you give it:
|
|
|
|
- Every file a command reads must lie inside the **read root**: `open_document`, `file.open`,
|
|
`file.openIdml`, `place_image` and `file.place` / `place.load` / `media.options` with a `path`,
|
|
`links.relink`, `links.relinkFolder`, `color.loadProfile`, `swatch.load`, `library.open`,
|
|
`book.open`, `data.source.select`, `data.merge` and `file.importXml` / `xml.loadDtd` with a `path`,
|
|
and `file.recovery.list` / `file.recovery.open` with a `dir`. A DesignCraft document names more
|
|
files than the agent does: linked images, data sources, book chapters, the `Document Fonts` folder
|
|
beside a document, an IDML package's linked images and the files an image cell of a merge names.
|
|
Those are read through the same checks, so a path in a document that leads outside the read root
|
|
reads as a file that isn't there.
|
|
- Every file a command writes must lie inside the **write root**: `save_document`, `export_png`,
|
|
`render_page` with a `path`, `file.save`, `file.saveAs`, `file.saveACopy`, `file.exportPdf`,
|
|
`file.exportEpub`, `file.exportFixedEpub`, `file.exportHtml`, `file.exportText`, `file.exportXml`,
|
|
`file.exportIdml`, `file.printBooklet`, `snippet.export`, `swatch.save`, `library.new` and the
|
|
library's saves, `book.new`, `book.add`, `book.paginate`, `book.syncStyles`, `book.exportPdf`,
|
|
`file.package`, `links.copyTo` and `file.recovery.save` / `file.recovery.open` with a `dir`
|
|
(reopening removes the entries it read). `file.package` and `links.copyTo` check their folder
|
|
before they create anything.
|
|
- Read and write access are separate. Give the same folder twice for both. A root left out grants
|
|
none of its access: with only `--automation-read-root` nothing can be written to disk. Without
|
|
either flag nothing changes.
|
|
- A path is made absolute against the server's working directory, then its links are followed: a
|
|
file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet,
|
|
its nearest existing folder's links are followed and the rest must be plain names, so `..` below a
|
|
folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would
|
|
create its target). The result is compared with the root by whole folder names: `/work2/x` is not
|
|
inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like
|
|
drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused.
|
|
- A refused path is an ordinary tool error the agent can read and correct, for example
|
|
`automation path rejected: outside the write root /work/project/out: /etc/x.designcraft` or
|
|
`automation filesystem access is not granted: write authority is absent: /work/project/out/x.designcraft`.
|
|
- The roots must be existing folders; a missing one stops the server with an error. Each flag is
|
|
given once (`--flag <dir>` or `--flag=<dir>`).
|
|
|
|
The flags confine the in-process server, so `--connect` together with them is an error: the server
|
|
would otherwise talk to an app that isn't confined. DesignCraft's desktop app has no equivalent flags
|
|
yet, so a running app (`designcraft --control`) is not confined. `--sample` is the operator's own
|
|
choice and is opened before the roots apply.
|
|
|
|
What the roots do not cover: commands that take no path still read and change the open document,
|
|
and `inspect_document` returns it. The system font scan, the installed fonts that `file.package`
|
|
copies, and the app's own recovery folder are fixed paths, not agent input, and are not checked. A
|
|
`Document Fonts` folder and each font file in it are checked before they are opened, so a link that
|
|
leads out of the read root is skipped with a note. `file.print` hands a PDF to the system's print
|
|
spooler, which is not a path the agent names.
|
|
Anything that reads or writes a path goes through one place in the engine (the `io` module). The
|
|
crates below the engine have no file access of their own: the IDML import and the font folders take
|
|
a reader or an access check from the engine, and ICC profiles arrive as bytes. A test scans the
|
|
source of every crate for the file system, however it is imported or spelled, and lists the few
|
|
files it excludes and why.
|
|
|
|
The checks hold for every thread of the server process. A path is checked, then opened: another
|
|
program that swaps a link in between can still win that race, so the roots keep an agent to the
|
|
files it was given rather than guarding against other software on the computer.
|
|
|
|
## Modes
|
|
|
|
| | Headless (`mcp`) | Connected (`mcp --connect PORT` or `HOST:PORT`) |
|
|
|---|---|---|
|
|
| Engine | In-process `designcraft_engine::Session`, starts with an empty Letter document (`--sample` opens the sample magazine) | The app's session, through its loopback control channel ([control-protocol.md](control-protocol.md)) |
|
|
| Rendering | `designcraft-render` (CPU) | The app's renderer |
|
|
| Window tools (`screenshot`, `click`, `drag`, `menu_list`, `ui_inspect`, `ui_set`, `dialog_*`) | Return an error explaining how to connect | Work |
|
|
| UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work |
|
|
|
|
In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so
|
|
what the agent does shows up live in the window. A failed request is not resent: the operation may have completed,
|
|
so inspect the document before retrying. The next request re-establishes the connection.
|
|
|
|
## Coordinates
|
|
|
|
Points (1/72 in), y down, in **spread** space: on a single-page spread the page's top-left is (0, 0); on a facing
|
|
spread the right page starts at x = page width. `inspect_document` lists page bounds per spread. Page indices in
|
|
`render_page` / `export_png` are 0-based. Commands that create items take `spread`: a body spread index, or
|
|
`{"kind": "parent", "index": 0}` for a parent spread (its index in `parents` of `inspect_document`); any other
|
|
value is an error. `pointer` events in connected mode may use `"space": "screen"` (egui points,
|
|
see `ui_inspect` → `canvasRect`).
|
|
|
|
## Text offsets
|
|
|
|
Positions in a story's text are **UTF-8 byte offsets**, not character counts: `anchor` / `focus` of `text.select`,
|
|
`start` / `end` of `story.replaceRange` and of `find.find` matches, the `pos` that `text.placeCaret`, `text.insert`
|
|
and `text.move` return, a story's `length` in `get_story` and `inspect_document`, and its `overset` position in
|
|
`get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15
|
|
and its second character starts at 3. An offset inside a character snaps back to the start of that character, except
|
|
in `story.replaceRange`: there an offset inside a character, an `end` past the end of the story or a `start` after `end`
|
|
is an error that gives the story length in bytes, and the story is left unchanged.
|
|
|
|
## Text and image fitting
|
|
|
|
Hyphenation is on by default for new text (`hyphenate: true`). To turn it off, select the text or a text frame,
|
|
then call `execute`:
|
|
|
|
```text
|
|
execute {"command": "type.para", "params": {"attrs": {"hyphenate": false}}}
|
|
```
|
|
|
|
With a text selection, this formats the selected paragraphs; with selected text frames, it formats their whole stories.
|
|
|
|
Use `execute` with `object.fit` to fit a selected graphic frame (or pass `ids`):
|
|
|
|
| `mode` | What it does |
|
|
|---|---|
|
|
| `fillProportionally` | Preserves aspect ratio, covers the frame and clips overflow. |
|
|
| `fitProportionally` | Preserves aspect ratio, shows the whole image and may leave empty space. |
|
|
| `fitContentToFrame` | Stretches the content to the frame's width and height. |
|
|
| `centerContent` | Keeps the current size and positions content at the fitting alignment (center by default). |
|
|
| `fitFrameToContent` | Resizes the frame to the content's bounds. |
|
|
|
|
## Tools
|
|
|
|
| Tool | What it does |
|
|
|---|---|
|
|
| `list_commands` | Every command (id, label, menu path, shortcut, params, enabled), with `filter` / `enabledOnly` |
|
|
| `execute` | Run any command: `{command, params}`. A params key the command doesn't take is a JSON-RPC `-32602` (invalid params) error naming it, as is an argument a tool doesn't take; `batch` checks every step before running the first |
|
|
| `batch` | Run `commands: [{command, params}]` (or `script` text, one `command.id {json}` per line) in order, stopping at the first error (reports `failedIndex`); `"$N.path"` parameters use earlier results ([agents.md](agents.md)) |
|
|
| `inspect_document` | Pages, spreads, items (ids, bounds, fill, story), stories (overset), layers, styles, swatches, selection |
|
|
| `get_story` | Text, frames, paragraphs, lines, overset of a story (`story` or text `frame`) |
|
|
| `set_story_text` | Replace a story's text (`\n` = new paragraph) |
|
|
| `new_document` | `file.new` (preset, intent, units, width, height, pages, startPage, facingPages, columns, gutter, margins, bleed, slug, title, primaryTextFrame) or `sample: true` |
|
|
| `open_document` / `save_document` | Native `.designcraft` files |
|
|
| `place_image` | Image from `path` or `base64` into `frame`, the selected frame, or a new frame at x, y, width |
|
|
| `render_page` | Render a page → MCP image content (PNG, base64); optional `path` to also save it |
|
|
| `export_png` | Write a page as PNG (or JPEG by extension) |
|
|
| `screenshot` | Whole app window → image content (connected only; captured to a temp file and read back) |
|
|
| `select_tool` | Activate a tool (`selection`, `type`, `rectangleFrame`, `line`, …) |
|
|
| `pointer` | Mouse gesture events (`down`/`drag`/`up`/`move`/`doubleclick`) for the active tool, optional `tool` |
|
|
| `key` | Key press with modifiers (headless: active tool → command shortcuts → tool shortcuts) |
|
|
| `type_text` | Type at the text insertion point (or into the focused dialog field in the app) |
|
|
| `ime` | Input method events at the Type tool's caret: `preedit` (marked text in place, `active` = converting clause in characters), `commit` (typed as one step) |
|
|
| `click` / `drag` | Real pointer input at screen points (connected only) |
|
|
| `menu_list`, `ui_inspect`, `ui_set` | Menu tree, UI state, change UI state (connected only) |
|
|
| `dialog_set` / `dialog_confirm` / `dialog_cancel` | Fill in and close the open dialog (connected only) |
|
|
|
|
Tool failures come back as results with `isError: true` and a message the model can act on; protocol errors use
|
|
JSON-RPC error codes. Resources: `designcraft://document` (document.inspect) and `designcraft://commands`.
|
|
|
|
## Example
|
|
|
|
```text
|
|
new_document {"preset": "A4", "margins": 36}
|
|
execute {"command": "frame.create", "params": {"rect": [36, 36, 559, 200], "content": "text", "text": "Hello"}}
|
|
→ {"id": 8, "story": 9}
|
|
set_story_text {"story": 9, "text": "Summer Fair\nSaturday in the park"}
|
|
execute {"command": "type.char", "params": {"size": 28}}
|
|
place_image {"path": "/tmp/photo.jpg", "x": 36, "y": 220, "width": 523}
|
|
render_page {"page": 0} → PNG image
|
|
```
|
|
|
|
Or with the Type tool, the way a user would:
|
|
|
|
```text
|
|
pointer {"tool": "type", "events": [{"kind": "down", "x": 72, "y": 72}, {"kind": "drag", "x": 300, "y": 200}, {"kind": "up", "x": 300, "y": 200}]}
|
|
type_text {"text": "Hello"}
|
|
key {"key": "Enter"}
|
|
key {"key": "Escape"}
|
|
```
|
|
|
|
## Community links
|
|
|
|
`app.links` returns the ArtCraft Discord (https://discord.gg/artcraft), the website (https://getartcraft.com), DesignCraft's app page (https://getartcraft.com/apps/designcraft), its GitHub repository (https://github.com/storytold/designcraft) and the issue tracker. In the running app, the UI commands `help.discord`, `help.appPage`, `help.github`, `help.issues`, `help.website`, `help.app {app}` open them in the browser and `help.about {tab?}` shows the About window (tabs `about`, `contributors`, `models`). The server's `serverInfo.websiteUrl` is the app page.
|