Skip to main content
Glama

opentoonz-mcp

Headless OpenToonz automation for AI agents — as a Model Context Protocol server.

opentoonz-mcp lets an agent drive OpenToonz — levels, xsheets, cells, rendering, video — entirely from the command line. No GUI, no plugin build, no mouse.

It works by running OpenToonz's built-in scripting engine in CLI mode and the official tcomposer headless renderer, then exposing the useful primitives as MCP tools.

CI e2e License: MIT Python 3.10+ Ruff

A scene built through the MCP tools: a static background, a bouncing ball animated on 1s, and a title card, each a separate level composited by OpenToonz and rendered headlessly

Three separately drawn levels — background, ball, title — stacked as columns on the xsheet, the static ones held with opentoonz_exposure, and composited by OpenToonz's own renderer. Reproduce it with examples/demo_reel.py.

A static scene pushed in on by an eased camera move: the camera starts wide, then glides into a close-up, with the markers visibly slowing at the start and end of the move

The camera moving over a static scene — pan and zoom written into the scene's own camera channels, with the push-in eased so it settles instead of stopping dead. Reproduce it with examples/camera_move.py.


Why

OpenToonz is a deep, industry-grade 2D animation suite, but its scripting API is headless-only and awkward to reach. This server turns the validated, hard-won runtime recipe into a small, safe, well-documented tool surface an agent can actually use:

  • Create a workspace → draw or import frames as a level → place cells on the xsheet → move the camera → render a frame to look at it → render a video.

  • A generic opentoonz_run_script escape hatch exposes the rest of the JS API.

  • Runs stay self-contained: an isolated HOME and a shared TOONZROOT, so nothing leaks into your real OpenToonz config.

Related MCP server: blender-2d-mcp

How it works

MCP client (agent)
      │  tool calls (stdio)
      ▼
opentoonz-mcp server
      │  writes <workspace>/scripts/*.toonzscript  +  PIL-drawn RGBA frames
      ▼
OpenToonz CLI  (script mode, no window)
      │
      ▼
tcomposer  (official headless renderer)  →  PNG frames  →  ffmpeg  →  MP4

The extracted AppImage binary is run directly — its AppRun launcher mangles CLI arguments and drops into GUI mode.

Requirements

Requirement

Notes

Python

3.10+

OpenToonz

v1.x with tcomposer. On Linux, an extracted AppImage works best.

ffmpeg

Only for opentoonz_render_video / opentoonz_render_gif

See docs/installation.md for how to obtain and point at a runtime.

Install

# from GitHub (no clone needed)
pipx install git+https://github.com/c0d33ngr/opentoonz-mcp.git
# or, pinned to a release
pipx install "git+https://github.com/c0d33ngr/opentoonz-mcp.git@v0.8.0"
# or, from a checkout
pipx install .
# or
uv tool install .
# or for development
pip install -e ".[dev]"

The opentoonz-mcp console script starts the stdio server. It also bootstraps the OpenToonz runtime, so an installed copy needs no clone:

opentoonz-mcp fetch-runtime --appimage ~/Downloads/OpenToonz.AppImage

Configure your MCP client

OpenCode (~/.config/opencode/opencode.jsonc):

{
  "mcp": {
    "opentoonz": {
      "type": "local",
      "command": ["opentoonz-mcp"],
      "enabled": true,
      "environment": {
        "OPENTOONZ_RUNTIME": "/path/to/opentoonz/squashfs-root"
      }
    }
  }
}

Claude Desktop / Cursor (mcpServers):

{
  "mcpServers": {
    "opentoonz": {
      "command": "opentoonz-mcp",
      "env": { "OPENTOONZ_RUNTIME": "/path/to/opentoonz/squashfs-root" }
    }
  }
}

Quickstart

Ask your agent to:

  1. opentoonz_create_workspace("my-short")

  2. opentoonz_draw_frames(...) — draw a few RGBA frames, or generate PNGs elsewhere

  3. opentoonz_import_images_as_level(...) — bring them in as a level

  4. opentoonz_exposure(...) — hold each drawing for N frames ([2] = on 2s)

  5. opentoonz_camera_move(...) — pan, zoom or rotate the camera over the shot (add "ease": true to a keyframe to soften that part of the move)

  6. opentoonz_render_frame(..., max_width=960) — look at the result cheaply

  7. opentoonz_render_video(...) — get an MP4 (or opentoonz_render_gif for a GIF)

Draw at the scene's resolution (opentoonz_scene_new(width=..., height=...) to change it from the 1920×1080 default) — levels composite 1:1, so art drawn at the scene's size fills the frame.

Or run the examples end-to-end:

OPENTOONZ_RUNTIME=/path/to/squashfs-root python examples/bouncing_ball.py  # one level → MP4
OPENTOONZ_RUNTIME=/path/to/squashfs-root python examples/demo_reel.py     # three levels → MP4 + the GIF above
OPENTOONZ_RUNTIME=/path/to/squashfs-root python examples/camera_move.py   # eased camera move → GIF

Tools

Tool

Purpose

opentoonz_health

Quick availability check

opentoonz_doctor

Full diagnostics (paths, versions, state; smoke=true runs a real script)

opentoonz_create_workspace

Create a workspace (idempotent)

opentoonz_list_workspaces

Inventory workspaces/scenes/videos

opentoonz_clean

Delete generated files to reclaim disk space (needs confirm=true)

opentoonz_delete_workspace

Remove a whole workspace (needs confirm=true)

opentoonz_scene_new

Create a scene, optionally setting resolution/fps

opentoonz_set_scene_settings

Change camera resolution / fps

opentoonz_import_images_as_level

Image sequence → raster level (additive, re-runnable)

opentoonz_extract_frames

Video → PNG drawings, as a level (via ffmpeg)

opentoonz_scene_info

Frames/columns/levels/cells

opentoonz_set_cells

Place cells on the xsheet

opentoonz_exposure

Hold each drawing for N frames (animate on 1s/2s/3s)

opentoonz_camera_move

Animate the camera (pan / zoom / rotate), with per-segment easing

opentoonz_draw_frame

Draw one RGBA frame (PIL primitives + image compositing)

opentoonz_draw_frames

Draw a whole sequence

opentoonz_tween_frames

Tween a drawing between two poses (move/scale/rotate/fade)

opentoonz_render_frame

Render one frame, returned inline as an image

opentoonz_render

Render PNG frames via tcomposer (optional frame range)

opentoonz_render_video

Render + ffmpeg → MP4 (optional audio, crf, scale)

opentoonz_render_gif

Render + ffmpeg → looping GIF

opentoonz_render_batch

Render every scene in a workspace (frames/video/gif) in one call

opentoonz_run_script

Arbitrary OpenToonz JS (escape hatch)

Full reference, including the drawing-op schema: docs/tools.md. The data-returning tools also return structured content (see docs/architecture.md), so results can be read as fields rather than parsed from text.

Resources

Beyond the tools, three read-only MCP resources expose the same workspace data as JSON, so a client can poll state without calling a tool:

Resource

Contents

opentoonz://workspaces

Every workspace with its scenes, videos and size

opentoonz://workspace/{name}

One workspace's contents

opentoonz://workspace/{name}/scene/{scene}

One scene's frames, columns, levels and cells

The first two read only the filesystem; the scene one reads through OpenToonz and so costs a script run.

Limitations

OpenToonz's JS API is a subset of the GUI, and some of the boundaries are sharp:

  • FX graphs, palette editing, camera splines, auto-inbetweening, and audio tracks are unreachable. There is also no way to delete a level or a cell, which is why level import reuses an existing level instead of replacing it. Camera moves (pan/zoom/rotate) do work, including eased segments — they are written into the camera's stage-object channels, which OpenToonz renders and keeps editable.

  • Vector (SVG) levels import but render blank headlessly, and setFrame() on a loaded level never reaches the renderer. Raster PNG levels are the supported path; passing an SVG to the import tool is rejected with that explanation.

  • Camera resolution is not scriptable, so opentoonz_set_scene_settings patches the scene file's XML — keeping the camera's physical size in step, so art drawn at the scene's size fills the frame.

  • Rendering needs a working tcomposer; Renderer.renderFrame deadlocks in CLI mode and is intentionally avoided.

  • Exercised on Linux only so far. Workspace locking uses fcntl; on Windows it degrades to a no-op (and warns once) rather than failing.

Workarounds and the full picture: docs/runtime-notes.md.

Troubleshooting

See docs/troubleshooting.md — it covers the common failure modes (GUI-mode hangs, black renders, tcomposer exceptions).

Development

pip install -e ".[dev]"
pytest -m "not e2e"      # fast unit tests
pytest -m e2e            # requires a runtime (OPENTOONZ_RUNTIME=...)
ruff check . && ruff format --check .
python -m build          # packaging check

The e2e suite needs a real OpenToonz runtime, so it is not part of the push/PR gate. To run it in CI on demand — or weekly — see .github/workflows/e2e.yml.

What the tests prove

  • Unit tests (pytest -m "not e2e") run on every push and PR across Python 3.10–3.12. They cover the Python logic and the scene-file edits: the camera channels, the easing markup and the scene-settings patch are asserted against synthetic .tnz files trimmed from a real OpenToonz 1.8 scene, so a regression in what gets written is caught without a runtime.

  • End-to-end tests (pytest -m e2e) drive a real OpenToonz runtime and are what proves the integration — that OpenToonz accepts, renders and re-saves what the tools write. They cannot run on the push/PR gate (OpenToonz publishes its Linux AppImage only as a login-gated workflow artifact, and it is not redistributed here), so they run from e2e.yml on demand and weekly, and upload the rendered frames and video as artifacts so a result can be inspected without a local runtime.

  • When the e2e job cannot find a runtime it skips with a warning rather than failing, so check its badge/run before assuming the integration was exercised. The integration contract is the part CI does not cover on its own.

Architecture notes live in docs/architecture.md.

License & attribution

MIT — see LICENSE. This is an unofficial project and is not affiliated with OpenToonz. OpenToonz is BSD-3-Clause and is not bundled here. See NOTICE.

Available Tools

23 tools
opentoonz_camera_moveOpentoonz Camera MoveA

Move the scene's camera over the shot — pan, zoom, and rotate.

keyframes: list of {"frame": int, "x": px, "y": px, "zoom": factor, "rotation": degrees, "ease": bool}. Only "frame" is required; the rest default to no movement (x=0, y=0, zoom=1, rotation=0, ease=false). "frame" is a 1-based shot frame — the same numbering as opentoonz_render_frame.

x/y are the camera's position in scene pixels: +x pans the view right and +y pans it up, so the artwork moves the opposite way. zoom is a magnification (2 renders the art twice as large), and rotation is in degrees, clockwise on screen, about the frame centre.

ease=true makes the camera glide out of that keyframe and into the next instead of moving at a constant speed — it eases the segment starting there, symmetrically, across every channel. Each segment eases on its own, so a run of eased keyframes settles at each pose; ease only the segments you want softened. The flag on the last keyframe does nothing, so adding ease=true to every keyframe eases the whole move.

The move is stored in the scene file as the camera's animation channels, so it survives later edits and stays editable in OpenToonz's Function Editor. Re-render to see it.

Inputs: scene (required), keyframes (required), workspace (required). Returns: result

opentoonz_cleanOpentoonz CleanA

Delete a workspace's generated files to reclaim disk space.

what selects drawings, render, scripts, orphans (drawings no scene references), or all (the default). Only the contents are removed — the workspace itself is kept, so it can be reused.

This is irreversible. When the client supports it, the user is asked to confirm through an elicitation prompt; otherwise pass confirm=true explicitly (which is also how automation and tests proceed).

Inputs: workspace (required), what, confirm.

opentoonz_create_workspaceOpentoonz Create WorkspaceA

Create a self-contained workspace folder (idempotent).

A workspace holds drawings/, render/, and scripts/. The shared OpenToonz 'stuff' tree is not copied per workspace (it lives once in the data home).

Inputs: name (required). Returns: result

opentoonz_delete_workspaceOpentoonz Delete WorkspaceA

Delete a whole workspace, including its scenes and renders.

This is the blunt instrument: opentoonz_clean empties a workspace but keeps it usable, while this removes it entirely. Irreversible: when the client supports it, the user is asked to confirm through an elicitation prompt; otherwise pass confirm=true explicitly.

Inputs: workspace (required), confirm.

opentoonz_doctorOpentoonz DoctorA

Full diagnostic: resolved paths, versions, and prepared state.

smoke=true also runs a trivial script through OpenToonz, which proves the runtime actually executes — not merely that the binaries exist. That launches OpenToonz, so it costs a few seconds and is off by default.

Inputs: smoke. Returns: result

opentoonz_draw_frameOpentoonz Draw FrameA

Draw one RGBA frame with PIL primitives and save it as a level drawing.

ops: list of {"op": "rect|ellipse|line|polygon|polyline|text|image", ...} — see docs/tools.md. The "image" op composites an existing PNG, so art made elsewhere can join a drawn frame. Saved to /drawings/<level_name>..png, ready for opentoonz_import_images_as_level.

Inputs: ops (required), workspace (required), level_name (required), frame, +3 more. Returns: result

opentoonz_draw_framesOpentoonz Draw FramesA

Draw a whole sequence of frames, one op-list per frame.

frames: list where each item is a list of ops for that frame (frame 1 first). Saves /drawings/<level_name>.%04d.png and returns the paths.

Inputs: frames (required), workspace (required), level_name (required), width, +2 more. Returns: result

opentoonz_exposureOpentoonz ExposureA

Time a level's drawings on the xsheet by holding each one for N frames.

exposures is either a single hold for every drawing ([3] = hold all on 3s) or one hold per drawing in order ([2, 4, 1]). Rows are contiguous, and the scene's frame count follows what is placed. The level must already be in the scene (see opentoonz_import_images_as_level).

Inputs: level (required), scene (required), exposures (required), workspace (required), +2 more. Returns: result

opentoonz_extract_framesOpentoonz Extract FramesA

Extract a video's frames into drawings/<level_name>.%04d.png, as a level.

How outside footage joins a scene: extract, then pass the returned paths to opentoonz_import_images_as_level. fps resamples (default keeps the source rate), start/end are seconds, and max_width downscales so a large source does not fill the workspace. max_frames caps the extraction (default 5000) so a long clip cannot fill the disk; raise it, or use start/end/fps, for more. Previous drawings for the same level are cleared first, so a shorter extract cannot leave orphaned frames.

Inputs: video (required), workspace (required), level_name (required), end, +4 more. Returns: result

opentoonz_healthOpentoonz HealthA

Quick availability check for the runtime, tcomposer, and ffmpeg.

Inputs: no named parameters. Returns: result

opentoonz_import_images_as_levelOpentoonz Import Images As LevelA

Import an ordered image sequence as a raster level and place it on the xsheet.

Additive: an existing scene is loaded first, so you can call this repeatedly with different level_name/column to build a multi-level scene. Images are converted to RGBA PNG named <level_name>.%04d.png, and levels composite 1:1 and centred — draw at the scene's size (1920x1080 by default) for full-frame output.

Re-importing the same level_name reuses that level and overwrites its drawings, so you can iterate on art without the level being duplicated.

Inputs: workspace (required), level_name (required), image_paths (required), scene, +2 more. Returns: result

opentoonz_list_workspacesOpentoonz List WorkspacesB

List workspaces with their scenes and rendered videos.

Inputs: no named parameters.

opentoonz_renderOpentoonz RenderA

Render a scene to PNG frames under /render/ with tcomposer.

By default the whole scene renders. Give start and end together to render an inclusive frame range instead — e.g. start=1, end=3 for frames 1-3 — which is much cheaper when you only need a section.

Inputs: scene (required), workspace (required), end, start, +1 more.

opentoonz_render_batchOpentoonz Render BatchA

Render several scenes in one call, reporting each scene's own result.

scenes defaults to every scene in the workspace. kind is frames, video or gif; fps applies to video/gif, start/end (frames only) limit an inclusive range, and crf/scale (video only) control the encode. Each output is named after its scene, so one batch produces one deliverable per scene.

One scene failing does not stop the rest: every outcome is reported, so a long batch is never lost to a single bad scene. The reply lists each scene as OK or FAILED, and the structured payload carries the same per-scene detail.

Inputs: workspace (required), crf, end, fps, +5 more.

opentoonz_render_frameOpentoonz Render FrameA

Render a single frame to a PNG — the agent's 'look' at the result.

The image is returned inline as well as on disk, so looking at a frame costs no extra round-trip. It is downscaled to 768px wide by default, so a large scene costs far fewer image tokens; pass max_width to change that, or max_width=0 for the full-resolution frame. Only the returned preview is affected — the scene's resolution is unchanged, and opentoonz_render / opentoonz_render_video output is never downscaled.

Inputs: scene (required), workspace (required), frame, out_name, +1 more.

opentoonz_render_gifOpentoonz Render GifA

Render a scene to a looping GIF — the shareable form of a short move.

fps sets playback speed (12–15 reads well for hand-drawn work), max_width downscales the GIF, and colors caps the palette (2–256). Frames come from the same tcomposer pass as opentoonz_render_video; use that for anything with audio or that must stay full resolution.

Inputs: scene (required), workspace (required), fps, colors, +2 more.

opentoonz_render_videoOpentoonz Render VideoB

Render a scene and mux the frames into an MP4 with ffmpeg.

audio (optional) is a path to an external soundtrack (mp3, wav, ...). It is encoded as AAC and cut to the video's length. OpenToonz's own audio columns cannot be reached from the scripting API, so audio always comes from a file. crf (0-51, lower is better) trades quality for file size, and scale (a positive factor, e.g. 0.5) resizes the video.

Inputs: scene (required), workspace (required), crf, fps, +3 more.

opentoonz_run_scriptOpentoonz Run ScriptA

Run arbitrary OpenToonz JavaScript headlessly (the full-power escape hatch).

The script can use the OpenToonz API: new Scene(path?), scene.newLevel(type, name), scene.loadLevel(name, "path/..png"), scene.setCell(row, col, level, fid), scene.getCell(row, col), scene.getLevels(), scene.save(path), new Image(path), image.save(path), level.setFrame(fid, image). Use print() for output; stdout is returned. Do NOT use new Renderer() — renderFrame deadlocks in CLI mode; use opentoonz_render instead. Use absolute paths.

Inputs: code (required), workspace (required), timeout. Returns: result

opentoonz_scene_infoOpentoonz Scene InfoB

Report a scene's frame/column counts, its levels, and its cell map.

The cell map is scanned row by row and stops at max_rows (default 1000); the output says so explicitly when a scene is taller than that.

Inputs: scene (required), workspace (required), max_rows.

opentoonz_scene_newOpentoonz Scene NewB

Create (or overwrite) an empty scene, optionally setting its resolution.

The default camera is 1920x1080. Levels composite 1:1 and centred, so draw your art at the scene's size for full-frame output.

Inputs: workspace (required), fps, scene, width, +1 more. Returns: result

opentoonz_set_cellsOpentoonz Set CellsA

Place/replace cells on a scene's xsheet.

placements: list of {"row": int, "col": int, "level": str, "fid": int|str}. Levels must already exist in the scene (see opentoonz_import_images_as_level). For the common animation task of holding each drawing for N frames, use opentoonz_exposure instead of computing rows yourself.

Inputs: scene (required), workspace (required), placements (required). Returns: result

opentoonz_set_scene_settingsOpentoonz Set Scene SettingsA

Change a scene's camera resolution and/or frame rate (e.g. 1080p -> 720p).

OpenToonz's scripting API has no setters for these, so the scene file's XML is patched directly; later edits to the scene keep the new settings. width and height may be given independently — the omitted one keeps its value. Re-render after changing this, and draw new art at the new size.

Inputs: scene (required), workspace (required), fps, width, +1 more. Returns: result

opentoonz_tween_framesOpentoonz Tween FramesA

Tween one drawing from a start pose to an end pose, saving the sequence.

OpenToonz's auto-inbetweening is GUI-only, so the in-betweens are rendered here and saved as /drawings/<level_name>.%04d.png, ready for opentoonz_import_images_as_level.

start/end each describe the drawing: {"path": "/abs/sprite.png", "xy": [x, y], "scale": 1.0, "rotation": 0, "opacity": 1.0}. xy is the drawing's CENTRE, so motion and rotation share one anchor; omitted fields stay neutral. ease=true leaves and arrives gently instead of at a constant speed.

Inputs: end (required), start (required), frames (required), workspace (required), +5 more. Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.9/5.0

    Scored across 23 tools

    Disambiguation5/5

    Each tool targets a distinct resource or operation: workspace management, scene creation/settings, drawing/import, xsheet timing, camera movemen, rendering, diagnostics, and scripting are all clearly separated. Overlaps like clean vs. delete_workspace and render vs. render_frame are explicitly contrasted in their descriptions, so an agent can tell them apart.

    Naming Consistency5/5

    All tools share the opentoonz_ prefix and use snake_case consistently. The verbs and nouns are predictable (e.g., create/list/delete workspace, draw/render/tween frame), with only minor stylistic variation like scene_info or exposure that remains readable and consistent.

    Tool Count4/5

    23 tools is on the high side but each maps to a distinct operation in a complex animation domain. The five render tools differ by output format or batching, and the surface does not feel bloated with redundant tools, though a few could theoretically be consolidated.

    Completeness4/5

    The set covers workspace lifecycle, scene creation/configuration, drawing/import/extraction, xsheet timing, camera moves, multiple render outputs, diagnostics, and a full scripting escape hatch. Minor gaps exist such as direct scene deletion or level deletion, but run_script and clean provide workarounds.

    Maintenance

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers