cavalry-mcp
<p align="center">
<img src="assets/banner.png" alt="cavalry-mcp — MCP server for Cavalry" width="100%">
</p>
# cavalry-mcp
[](https://nodejs.org)
[](https://www.typescriptlang.org/)
[](https://modelcontextprotocol.io)
**Drive [Cavalry](https://cavalry.scenegroup.co/) from an LLM.** An [MCP](https://modelcontextprotocol.io) server that exposes Cavalry's 2D motion-graphics engine as 28 structured tools — create layers, wire procedural graphs, set keyframes, apply easing, and render frames, all from natural language.
Point Claude at a composition and say *"build me a looping loader animation with eight dots on a circle"* — it introspects the scene, builds the duplicator graph, keyframes the rotation, and renders a PNG to check its own work.
---
## Contents
- [Why](#why)
- [Architecture](#architecture)
- [Installation](#installation)
- [Configuration](#configuration)
- [Tool Reference](#tool-reference)
- [Attribute Reference](#attribute-reference)
- [Batching](#batching)
- [Security](#security)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
---
## Why
Cavalry ships a rich JavaScript API, but it lives behind an in-app editor — there is no out-of-process entry point. `cavalry-mcp` adds one, and wraps it in a **typed, validated dispatcher** rather than handing the model a raw `eval`.
That distinction is the whole design:
| | Raw script execution | `cavalry-mcp` |
|---|---|---|
| Input validation | none | Zod schema per tool |
| Injection surface | full | closed — args are JSON, never interpolated into source |
| Failure mode | silent console error | typed error taxonomy |
| Blast radius | filesystem + subprocess | scene graph only |
The model gets exactly the verbs it needs, and every one of them is checked before it reaches Cavalry.
---
## Architecture
```mermaid
graph LR
A[MCP Client<br/>Claude Code / Desktop] -->|stdio<br/>JSON-RPC| B[cavalry-mcp<br/>Node + TypeScript]
B -->|HTTP 127.0.0.1:8080| C[cavalry-bridge.js<br/>in-app WebServer]
C -->|api.*| D[Cavalry<br/>scene graph]
```
Three processes, two hops:
1. **MCP server** (`dist/src/index.js`) — speaks JSON-RPC over stdio, validates tool arguments with Zod, and serialises each call into a versioned dispatch payload.
2. **Bridge** (`cavalry-bridge.js`) — runs *inside* Cavalry via `api.WebServer`, listening on loopback. It routes ops to `api.*` calls and returns results.
3. **Cavalry** — the host application.
### Request lifecycle
The bridge processes POSTs on Cavalry's realtime callback (~60 fps) and stashes the result for retrieval, so the transport is **POST-then-poll** rather than request/response:
```
POST /post { payloadVersion: 1, op: "keyframe", args: {...}, _rid: <uuid> }
GET /get → poll every 30 ms until the body contains our _rid
```
Every request carries a UUID `_rid`. The client only accepts a response containing its own id, so concurrent calls can never read each other's results.
Results come back inside a sentinel envelope, because `api.log()` may write to the same buffer:
```
<CAVALRY_MCP_RESULT>{"ok":true,...}<CAVALRY_MCP_RESULT>
```
The parser scans for the **last** matching tag pair, making it resilient to log noise that happens to contain tag-like text.
### Error taxonomy
Failures are classified rather than collapsed into a string, so an agent can tell "Cavalry isn't running" from "you passed a bad attribute path":
| Kind | Meaning |
|---|---|
| `validation` | Bad input, rejected before dispatch |
| `transport` | Connection refused or timed out |
| `http` | Non-200 from the bridge |
| `envelope` | Response body unparseable |
| `remote` | Cavalry returned `{ ok: false }` |
---
## Installation
### Prerequisites
- **Node.js ≥ 18** (the transport uses global `fetch`)
- **Cavalry 2.7+**
- An MCP client — [Claude Code](https://claude.com/claude-code), Claude Desktop, or any MCP-compatible host
### 1. Build the server
```bash
git clone https://github.com/nxnai/cavalry-mcp.git
cd cavalry-mcp
npm install
npm run build
```
Verify the build with the offline test suite (no running Cavalry required):
```bash
npm test
```
### 2. Install the bridge
Copy `cavalry-bridge.js` into Cavalry's user scripts folder:
| OS | Path |
|---|---|
| Windows | `%APPDATA%\Cavalry\Scripts\` |
| macOS | `~/Library/Application Support/Cavalry/Scripts/` |
Scripts in that folder appear under **`Window > Scripts`** immediately — no Cavalry restart needed. Run **`cavalry-bridge`** from that menu to start the listener.
> [!IMPORTANT]
> The bridge is **session-scoped**. Re-run it from the Scripts menu each time you restart Cavalry. The MCP server does not need restarting.
> [!WARNING]
> **Cavalry must stay frontmost while tools run.** Cavalry pauses script callbacks when the app is in the background, which stalls the bridge until you refocus it. Requests are queued rather than lost, and the client fails fast with a `frozen` error instead of hanging. See [Troubleshooting](#troubleshooting).
Alternatively, paste the script into **`Window > JavaScript Editor`** and run it there.
### 3. Register the MCP server
Add to your MCP client config — `.mcp.json` in your project root for Claude Code, or `claude_desktop_config.json` for Claude Desktop:
```json
{
"mcpServers": {
"cavalry": {
"command": "node",
"args": ["/absolute/path/to/cavalry-mcp/dist/src/index.js"],
"env": {
"CAVALRY_MCP_HOST": "127.0.0.1",
"CAVALRY_MCP_PORT": "8080",
"CAVALRY_MCP_TIMEOUT_MS": "15000"
}
}
}
}
```
> [!NOTE]
> On Windows, escape backslashes in JSON (`C:\\Users\\...`) or use forward slashes.
Restart your MCP client, then confirm the round-trip:
```
cavalry_ping → { "ok": true, "platform": "Windows" }
```
---
## Configuration
All configuration is via environment variables, parsed and validated once at startup. Invalid values fail fast with a descriptive error rather than silently falling back.
| Variable | Default | Description |
|---|---|---|
| `CAVALRY_MCP_HOST` | `127.0.0.1` | Bridge host |
| `CAVALRY_MCP_PORT` | `8080` | Bridge port — must match `PORT` in `cavalry-bridge.js` |
| `CAVALRY_MCP_TIMEOUT_MS` | `15000` | Per-request timeout |
| `CAVALRY_MCP_ENABLE_RUN_SCRIPT` | *(unset)* | Set to `1` to expose `cavalry_run_script`. See [Security](#security). |
Changing the port requires editing **both** sides — the env var and `PORT` at the top of `cavalry-bridge.js`.
---
## Tool Reference
28 tools by default; 29 with `cavalry_run_script` enabled.
### Introspection
| Tool | Arguments | Returns |
|---|---|---|
| `cavalry_ping` | — | `{ ok, platform }` |
| `cavalry_list_layers` | — | `{ layers: [{ id, type, name }] }` |
| `cavalry_get_selection` | — | `{ selection: string[] }` |
| `cavalry_composition_info` | — | `{ compId, width, height, startFrame, endFrame, fps, frame }` |
| `cavalry_get_attribute` | `layerId`, `attrPath` | attribute value |
| `cavalry_bounding_box` | `layerId`, `worldSpace?` | `{ x, y, width, height, centre, left, right, top, bottom }` |
| `cavalry_list_attributes` | `layerId`, `includeValues?` | `{ layerType, count, attributes: [{ id, type, niceName, animated, children? }] }` |
| `cavalry_list_layer_types` | `includeExperimental?` | `{ count, layerTypes: string[] }` |
| `cavalry_inspect_connections` | `layerId` | `{ incoming, outgoing, animated }` |
> [!TIP]
> **Never guess an attribute path — call `cavalry_list_attributes` first.** Cavalry's docs list attributes by their UI label, not their scripting id, and the two do not reliably match: the documented "Grouping" and "Custom String" on a Shuffle String Manipulator simply do not exist as scripting paths. A wrong path fails *silently* on write, so guessing produces changes that report success and do nothing.
>
> Likewise, `cavalry_create_layer` takes a scripting type id, not a display name — "Shuffle String Manipulator" is `shuffleString`. Use `cavalry_list_layer_types` rather than inferring it.
### Creation
| Tool | Arguments |
|---|---|
| `cavalry_create_layer` | `layerType`, `name?` |
| `cavalry_create_primitive` | `generator`, `name?` |
`generator` ∈ `ellipse` · `rectangle` · `star` · `polygon` · `ring` · `arrow` · `superEllipse` · `line`
`layerType` ∈ `textShape` · `null` · `duplicator` · `stagger` · `basicShape`
> [!TIP]
> Prefer `cavalry_create_primitive` over `create_layer` + `set_generator` — one round-trip instead of two.
### Mutation
| Tool | Arguments |
|---|---|
| `cavalry_set_attribute` | `layerId`, `attributes` *(map of path → value)* |
| `cavalry_set_generator` | `layerId`, `generator`, `attrId?` *(default `"generator"`)* |
| `cavalry_select` | `layerIds` |
| `cavalry_delete_layers` | `layerIds` |
| `cavalry_duplicate` | `layerIds` — restores prior selection afterward |
| `cavalry_parent` | `childIds`, `parentId` |
| `cavalry_add_dynamic_attribute` | `layerId`, `attrName`, `attrType` |
| `cavalry_connect` | `fromLayerId`, `fromAttrId`, `toLayerId`, `toAttrId`, `force?` |
### Animation
| Tool | Arguments |
|---|---|
| `cavalry_keyframe` | `layerId`, `frame`, `attributes` |
| `cavalry_magic_easing` | `layerId`, `attrPath`, `frame`, `easingType` |
| `cavalry_delete_keyframe` | `layerId`, `attrPath`, `frame` |
| `cavalry_set_frame` | `frame` |
| `cavalry_play` | — |
`easingType` ∈ `SlowIn` · `SlowOut` · `SlowInSlowOut` · `VerySlowIn` · `VerySlowOut` · `SpringIn` · `SpringOut` · `BounceIn` · `BounceOut` · `Custom` · `None`
> [!WARNING]
> Magic Easing has no `Linear`, `EaseIn`, `EaseOut`, `BackIn`, or `ElasticIn`. The enum is enforced at the schema layer, so invalid names are rejected before reaching Cavalry.
### I/O
| Tool | Arguments |
|---|---|
| `cavalry_save_scene` | `filePath?` — omit to save in place |
| `cavalry_load_scene` | `filePath` |
| `cavalry_render_png` | `filePath`, `frame?`, `scale?` *(default `100`)* |
### Batch
| Tool | Arguments |
|---|---|
| `cavalry_batch` | `ops: [{ op, args? }]` |
---
## Attribute Reference
Cavalry's attribute paths are not always guessable, and a wrong path **fails silently** — the call succeeds, nothing changes. These are the verified ones.
### Transform
| Path | Type | Notes |
|---|---|---|
| `position.x` / `position.y` | number | |
| `scale.x` / `scale.y` | number | `1.0` = 100%. **This is how you resize a shape.** |
| `rotation.z` | number | Degrees. `rotation` is a 3D vector — always use `.z` in 2D. |
| `opacity` | number | 0–100 |
### Appearance
| Path | Type | Notes |
|---|---|---|
| `material.materialColor` | string | Hex, `"#rrggbb"` |
| `generator.dimensions` | object | **GET only.** Returns `{x, y}`; setting it throws a console error. |
### Duplicator
| Path | Type | Notes |
|---|---|---|
| `generator.count` | number | Distribution point count |
| `generator.radius` | number | Circle distribution radius |
| `generator.startAngle` / `generator.angle` | number | Arc control |
| `shapeScale.x` / `shapeScale.y` | number | Per-copy scale |
| `shapeRotation` | number | Per-copy rotation |
| `shapeOpacity` | number | Per-copy opacity |
### Text
| Path | Type | Notes |
|---|---|---|
| `text` | string | Plain string |
| `fontSize` | number | |
| `autoWidth` | boolean | `false` by default, which causes wrapping |
| `horizontalAlignment` | number | `0` left · `1` centre · `2` right |
### Composition
| Path | Type | Notes |
|---|---|---|
| `resolution.x` / `resolution.y` | number | Read-only |
| `startFrame` / `endFrame` / `fps` | number | |
### Common pitfalls
| Instead of | Use |
|---|---|
| `width`, `height`, `size` | `scale.x` / `scale.y` |
| `fill.color`, `color`, `material.color` | `material.materialColor` |
| `rotation` | `rotation.z` |
| setting `generator.dimensions` | `scale.x` / `scale.y` |
### Layer IDs
IDs follow `typeName#number` — `basicShape#1`, `duplicator#2`, `textShape#3`. Counters increment **globally per type and never reset**, even after deletion. Always discover them with `cavalry_list_layers`; never assume.
### Sizing worked example
Primitives are created at 200×200. For a 1920×1080 background:
```
cavalry_create_primitive(generator: "rectangle", name: "BG")
cavalry_set_attribute("basicShape#1", {
"scale.x": 9.6, // 1920 / 200
"scale.y": 5.4, // 1080 / 200
"material.materialColor": "#101014"
})
```
### Duplicator worked example
Shapes are **connected** to a duplicator, not parented to it:
```
cavalry_create_primitive(generator: "ellipse", name: "Dot")
cavalry_create_layer(layerType: "duplicator", name: "Ring")
cavalry_set_generator("duplicator#1", generator: "circleDistribution")
cavalry_set_attribute("duplicator#1", { "generator.count": 8, "generator.radius": 200 })
cavalry_connect(
fromLayerId: "basicShape#1", fromAttrId: "id",
toLayerId: "duplicator#1", toAttrId: "shapes"
)
```
> [!IMPORTANT]
> The source shape's own `scale` does **not** affect the duplicated copies. Use `shapeScale.x` / `shapeScale.y` on the *duplicator*.
---
## Batching
`cavalry_batch` collapses a sequence of operations into a single HTTP round-trip. On a multi-step build this is the difference between 20 polls and one.
```json
{
"ops": [
{ "op": "createPrimitive", "args": { "generator": "ellipse", "name": "Dot" } },
{ "op": "set", "args": { "layerId": "basicShape#1",
"attributes": { "scale.x": 0.25, "scale.y": 0.25 } } },
{ "op": "keyframe", "args": { "layerId": "basicShape#1", "frame": 0,
"attributes": { "rotation.z": 0 } } },
{ "op": "keyframe", "args": { "layerId": "basicShape#1", "frame": 250,
"attributes": { "rotation.z": 360 } } }
]
}
```
Supported ops: `create` · `createPrimitive` · `set` · `setGenerator` · `select` · `delete` · `duplicate` · `parent` · `addDynamic` · `keyframe` · `magicEasing` · `deleteKeyframe` · `setFrame` · `play` · `saveScene` · `loadScene` · `renderPng`
Two constraints worth internalising:
> [!WARNING]
> **Fail-fast, not atomic.** If op 4 fails, ops 1–3 have already been applied. There is no rollback.
> [!WARNING]
> **No ID substitution.** IDs created earlier in a batch are not interpolated into later ops. Either predict the sequential IDs, or split the batch and call `cavalry_list_layers` in between.
---
## Security
### Argument injection is closed by construction
Tool arguments are transmitted as **JSON data** and dispatched by an `op` string inside Cavalry. They are never concatenated into JavaScript source, so there is no string-escaping boundary to get wrong.
The test suite treats this as a first-class invariant, with dedicated cases for:
- backtick / template-literal injection
- double-quote escape and chained calls
- `</script>` tag injection
- U+2028 / U+2029 line-separator injection
### `cavalry_run_script` is opt-in
Setting `CAVALRY_MCP_ENABLE_RUN_SCRIPT=1` registers a tool that executes **arbitrary JavaScript** in Cavalry, bypassing the dispatcher entirely. Cavalry's `api` namespace includes filesystem and subprocess access, so this grants the model both.
It is off by default, and the server prints a warning to stderr when it is on. **Leave it disabled** unless you are debugging the bridge itself.
### Network exposure
The bridge binds to `127.0.0.1` and performs **no authentication** — any local process can drive it while it is running. Do not bind it to a routable interface, and stop it when you are not using it.
---
## Troubleshooting
**`[transport] Cannot reach cavalry-mcp bridge at http://127.0.0.1:8080`**
The bridge isn't running. Cavalry being open is not sufficient — run `Window > Scripts > cavalry-bridge`. Confirm the port is live:
```bash
# Windows
netstat -ano | findstr :8080
# macOS
lsof -i :8080
```
Remember the bridge dies with the Cavalry session.
**`[frozen] Cavalry accepted the request but never processed it`**
Cavalry pauses script callbacks whenever it is not the active application, so the bridge stops draining its queue the moment you switch to another window. Bring Cavalry to the front — the backlog runs on refocus and nothing is lost.
This is a Cavalry-level gate, not a tuning problem. Measured: 15/15 requests fail over 81s while backgrounded, 15/15 succeed focused. `setRealtime()` (60/s), `setHighFrequency()` (1/s) and the default (3/s) are the same poll driver at three intervals, and all three are equally paused — so changing the rate does nothing. Keep Cavalry frontmost for the duration of an agent-driven session.
**Tools don't appear in the client**
Restart the MCP client after editing its config. Check that `args` points at `dist/src/index.js` — note the `src` segment, which follows from `rootDir: "."` in `tsconfig.json`. Run `npm run build` if `dist/` is missing.
**An attribute set reports success but nothing changes**
The path is almost certainly wrong — Cavalry does not error on unknown paths. Read it back with `cavalry_get_attribute`, cross-check the [Attribute Reference](#attribute-reference), and verify visually with `cavalry_bounding_box` or `cavalry_render_png`.
**`[envelope] No envelope tags found in response`**
The bridge returned output without a complete sentinel pair — usually a stale or partially-pasted bridge script. Re-copy `cavalry-bridge.js` and re-run it.
**Requests time out under load**
Heavy renders can exceed the default 15 s. Raise `CAVALRY_MCP_TIMEOUT_MS`.
---
## Development
```bash
npm install # install dependencies
npm run build # tsc → dist/
npm test # 18 unit tests, no live Cavalry needed
npm start # run the server standalone
```
### Layout
```
src/
├── index.ts # server entry — tool registration
├── config.ts # env parsing, validated at import
├── errors.ts # CavalryError taxonomy
├── helpers.ts # dispatch + MCP result shaping
├── bridge/
│ ├── dispatch.ts # payload construction
│ ├── dispatcher-source.ts
│ └── envelope.ts # sentinel-tag parser
├── transport/
│ └── bridge.ts # POST-then-poll HTTP client
└── tools/
├── introspection.ts creation.ts mutation.ts
├── animation.ts io.ts batch.ts
└── run-script.ts # gated behind env flag
cavalry-bridge.js # runs inside Cavalry
test/test.ts # dispatcher, envelope, injection, protocol
```
### Adding a tool
1. Add the `op` branch to `dispatch()` in `cavalry-bridge.js`.
2. Register the tool with a Zod schema in the matching `src/tools/*.ts`.
3. If it should be batchable, add the op name to the enum in `src/tools/batch.ts`.
4. Cover the routing in `test/test.ts`.
The build output must stay in sync with the bridge — after editing `cavalry-bridge.js`, re-copy it to the Scripts folder and re-run it in Cavalry.
TDQS
Scored across 28 tools
The tools cover distinct operations such as layer manipulation, attribute management, keyframing, and rendering. Each tool has a clear, unique purpose with no overlapping functions, and descriptions further clarify any potential confusion.
All tools follow a consistent 'cavalry_verb_noun' naming convention using snake_case. This pattern is uniform across the entire set, making it easy to predict tool names and understand their roles.
With 28 tools, the server covers a broad range of functionality for an animation tool. While slightly on the higher side, each tool serves a specific purpose and the count is justified by the complexity of the domain.
The server includes tools for creation, deletion, duplication, parenting, attributes, keyframing, animation control, rendering, and inspection. Minor gaps like a stop playback tool are present, but overall coverage is robust and supports most workflows.