cavalry-mcp
MCP server for driving Cavalry's 2D motion-graphics engine from LLMs, enabling scene creation and animation via natural language.
README
<p align="center"> <img src="assets/banner.png" alt="cavalry-mcp — MCP server for Cavalry" width="100%"> </p>
cavalry-mcp
Drive Cavalry from an LLM. An MCP 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
- Architecture
- Installation
- Configuration
- Tool Reference
- Attribute Reference
- Batching
- Security
- Troubleshooting
- 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
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:
- 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. - Bridge (
cavalry-bridge.js) — runs inside Cavalry viaapi.WebServer, listening on loopback. It routes ops toapi.*calls and returns results. - 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, Claude Desktop, or any MCP-compatible host
1. Build the server
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):
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
frozenerror instead of hanging. See 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:
{
"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. |
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_attributesfirst. 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_layertakes a scripting type id, not a display name — "Shuffle String Manipulator" isshuffleString. Usecavalry_list_layer_typesrather 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_primitiveovercreate_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, orElasticIn. 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
scaledoes not affect the duplicated copies. UseshapeScale.x/shapeScale.yon 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.
{
"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_layersin 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:
# 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, 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
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
- Add the
opbranch todispatch()incavalry-bridge.js. - Register the tool with a Zod schema in the matching
src/tools/*.ts. - If it should be batchable, add the op name to the enum in
src/tools/batch.ts. - 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.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.