Skip to content

Releases: MalloyTheDev/aseprite-mcp

v0.10.0 - the results started saying what the calls did

Choose a tag to compare

@MalloyTheDev MalloyTheDev released this 03 Oct 04:02
08d8c55

Five new tools, 147 to 152, and none of them is what this release is about.

A torch-lit dungeon wall with a bound chest on the flagstones, the flame flickering and its light falling off across the room

An agent driving this server cannot see the screen, so a result that describes what a call did is not a nicety, it is the only feedback there is. This release is about the places where that description was missing, or worse, wrong.

Six tools grew a field for something a caller previously had to find out by looking at the art. shade_region_by_light and specular_highlight report region_components and region_bounds, because base_color scopes a pass by colour distance and two materials' ramps are routinely closer than the default tolerance of 24: every step of a gold ramp from generate_ramp is within 24 of a step of a brass one, so a pass aimed at one object silently reshaded another and region_pixels could not say so, because a count cannot tell one region from four. generate_ramp reports distinct, because lightness clamps at both ends and nine steps in can be eight colours out, and the cost of that silence landed two tools later in a specular_highlight refusal that talked about the shading pass. dither_band reports from_color and to_color, because its steps count from 1 while the ramp beside them counts from 0, and an off-by-one still names an adjacent pair, so the call succeeded on the wrong boundary.

And pixels_outside_selection on a write a mask refused entirely. The result harness attached the pixel counters under one gate that _px_masked was read inside but was not part of, so a write that drew nothing left the other three counters at zero, the whole block was skipped, and the count went with it. ok: true, selection_applied: true, and no number anywhere, in the one case where the number is the entire answer.

A selection means it now

Nine write paths were ignoring the active selection and reporting selection_applied: true anyway, which is the same failure from the other side: not a missing number but a false one. Scope an edit to a character's armour, run adjust_brightness_contrast, and you recoloured the whole character while being told the selection had been applied.

img_set was the only write that consulted the mask. The anti-aliased coverage write, the flood fill behind fill_area, every per-pixel effect pass, replace_color in both the effects module and the batch runner, add_outline's marks and mirror_layer's two axes all called Image:drawPixel directly, so they skipped the check and the counter together. The correct behaviour sat one line away: hard-edged draw_line not only clipped, it reported pixels_outside_selection: 8 on the same canvas where the others reported nothing.

The rule lives in one function now. Six writes deliberately do not use it, each saying why in a comment, because a selection is in canvas space and a tileset tile's own image, a grid of tile indices addressed by column and row, and a palette reindex are not. Sweeping every drawPixel would have broken three tools.

The showcase was redrawn, then measured

The item sheet was four versions of one exercise and nothing in the gallery showed the tools working together. It is five different kinds of object now, and dungeon.py is new: thirty-five wall blocks taking their form from a single shade_region_by_light call, a light falloff stepped down the stone's own ramp a rectangle per course so every cut lands on a joint, a chest casting onto the floor layer and clipped to it, and a four-frame flicker with four durations. zorder.py and quantize.py give set_cel_z_index and quantize_palette a picture each.

Then it was reviewed at native scale on a checkerboard, which found ten defects in art that looked finished at the size the README shows it. Nine of the ten were invisible in a thumbnail. Every one of them had passed the checks the generators already carried: the cell-colour census proved no colour crossed a cell and said nothing about whether the glint inside it was on the right object, and the falloff probe went on passing while the far wall was one tile repeated.

An assertion covers the failure it was written for and no other. That is the line this release earned, and it is written into both READMEs.

Three of the ten turned on the same realisation, that the answer was to stop handing a shape to a tool which describes a different kind of shape. A sword blade is a prism and is painted as seven columns. A sheet of paper is flat and takes four column bands. A scroll's roll is a cylinder and is banded by row, brightest a third of the way down.

Four behaviour changes

This is 0.10.0 rather than 0.9.1 because a caller relying on any of these sees a difference:

  • glow and cast_shadow reuse their own effect layer across frames where they used to refuse any existing new_layer name. The guarantee was always about a cel, and it was enforced on a layer, which spans every frame: a four-frame flicker needed four glow layers holding one cel each. A second call for the same layer and frame is still refused.
  • create_sprite(overwrite=True) discards the selection sidecar it would previously have handed to the new sprite, and reports discarded_selection when there was one. A selection is not stored in the .aseprite file, so it lives in a .msk beside it; replacing the sprite used to leave that behind, and the new sprite then opened carrying a selection from a sprite that no longer existed.
  • The nine write paths above clip where they did not.
  • generate_ramp always returns distinct, and a warnings entry when the ramp clipped, naming the nearest light_range that would not.

Also

Five new tools: set_cel_z_index for a limb in front of the body on one frame and behind it on the next without restructuring the layer stack, set_properties and get_properties for the custom-property store a sprite, layer, cel, tag, slice or tile carries inside the file, export_spritesheet_packed, and quantize_palette to derive a small palette from the artwork rather than map onto one that exists.

How a release is cut is written down, in CONTRIBUTING.md, because it had been improvised: v0.1.0 through v0.7.0 each carry a wheel and an sdist and v0.8.0 through v0.9.0 carry none, for no recorded reason. This project is not on PyPI, so a release asset is the only way to install a pinned version. Both artifacts are attached here and will be from now on.

The releases list was also pruned: five releases cut within six hours of each other on the first day, with no downloads between them, are gone. Every tag survives and CHANGELOG.md carries every version, so nothing left the record.

Gate

ruff check src tests scripts clean, gen_tool_docs.py --check in sync at 152 tools, 1178 pure tests passed and 1758 passed with 3 skipped against a real Aseprite 1.3.18.6 in 13m38s, on Python 3.10 through 3.14. All nine showcase generators reproduce their twelve committed images byte for byte into an empty workspace.

v0.9.0 - the server can check its own work

Choose a tag to compare

@MalloyTheDev MalloyTheDev released this 01 Oct 05:31
d6092bb

Thirteen new tools, 134 to 147. The ones that matter are not the ones that draw.

A sphere in four stages: flat fill, form shading on a ramp, contact shadow, and a specular highlight

An agent driving this server cannot see the screen, and the gap that leaves is not "is this any good" but "did my last call do what I meant". assess_sprite already answered the first. diff_sprites answers the second: it compares two frames and splits the difference into pixels that entered or left the silhouette, pixels repainted inside it, and pixels that changed only their alpha, because those are three different bugs and a single changed-pixel count reports a shading pass that ate the outline and one that worked as the same number. When nothing changed it says so loudly, because an edit that went to the wrong layer, or was scoped by a selection nobody cleared, or was clipped off the canvas, looks exactly like an edit that was not needed.

It earned itself immediately: a specular highlight turned out to be an invisible no-op by default, because shade_region_by_light spreads the ramp's top step across the whole lit side, so a glint in that same step changes nothing. diff_sprites reported identical on a highlight that claimed three pixels.

Indexed colour mode works at all now. A new indexed sprite had 256 identical black palette entries, so every colour was equidistant from every entry, the first index won, and index 0 is the transparent one. Every draw landed invisibly and reported the pixels it had written. Converting an existing sprite to indexed was worse: it mapped the art onto whatever palette the file happened to carry, and a saved RGB sprite carries one transparent entry, so the art did not survive.

The top of the ramp. specular_highlight, cast_shadow and glow, all built from ramp steps arranged in space rather than from alpha, which is the whole premise of the shading layer: a glow of blended alpha moves every pixel off the palette, and palette_conformance is the measurement that catches it.

Inbetweens. tween_cels interpolates a transform rather than pixels, because a cross-fade of two drawings is a double exposure and reads as a mistake. smear_frame is the piece of craft the server could not do at all.

Three behaviour changes

This is 0.9.0 rather than 0.8.2 because a caller relying on any of these breaks:

  • set_color_mode(..., "indexed") builds the palette from the art by default rather than mapping onto the carried palette, and refuses a conversion that would make drawn pixels disappear. The old mapping is palette_source="keep".
  • create_sprite(color_mode="indexed") produces a usable 33-colour palette instead of 256 identical blacks.
  • color_mode="rgba", an undocumented alias previously accepted as RGB, is refused, matching what set_color_mode already did.

Also

Python 3.14 is supported and tested. requires-python = ">=3.10" had no upper bound, so it already admitted 3.14 while CI stopped at 3.13; the matrix now covers it, and the per-minor PyPI classifiers exist for the first time.

assess_sprite, diff_sprites and get_selection now advertise readOnlyHint. All three write nothing and none of them said so, which cost an approval prompt on exactly the tools an agent calls after every pass.

The changelog gained the path-sandbox hardening that v0.8.0 shipped and never recorded: the junction that could disclose filenames from outside the workspace, the read that created directory trees, the Windows path components that are not files, the default workspace landing inside the Python installation, and the null byte that was only rejected because Path.resolve() used to raise. Those seven were absent from the entire changelog, which is why SECURITY.md became their only record and drifted into citing a release that was never published.

Verification

1453 tests pass against a real Aseprite 1.3.18.6, 957 of them on CI across Python 3.10 through 3.14. Every committed showcase image is reproduced byte for byte by a script in scripts/showcase/, because pictures that advertise a tool should change when the tool changes.

Full detail in the changelog.

v0.8.1 - showcase art redrawn, and reproducible

Choose a tag to compare

@MalloyTheDev MalloyTheDev released this 30 Sep 20:22
b1a49b3

A follow-up to v0.8.0, which was tagged the hour before the showcase art was redrawn. No code changed.

A heart, a coin, a potion and a sword, each shaded on its own ramp

The item sheet and the eight-direction sheet were from the earliest version of the server: the sword was a clipped diagonal with a floating pommel, and three of the eight facings were indistinguishable. Both are redrawn with the tools as they are now, along with the tilemap scene and the skeleton.

The part that lasts: the generators are in scripts/showcase/ and reproduce every committed image byte for byte. Pictures that advertise a tool should change when the tool changes, and a script is the only way to know they still match.

They also demonstrate the documented practice rather than working around it. shade_region_by_light refuses a region too thin to have an interior, saying so and pointing at draw_pixels; the potion's cork, the sword's crossguard and its grip are lit by hand for exactly that reason.

v0.8.0 - shading, animation, selections, and 134 tools

Choose a tag to compare

@MalloyTheDev MalloyTheDev released this 30 Sep 19:04
33c536d

The release that made the server usable by any MCP client and gave it an opinion about pixel art. 96 tools grown to 134, four new domains, and two defects that silently corrupted files closed.

One disc shown flat, then shaded by light direction, then outlined and dithered

Shading that stays on the palette

shade_region_by_light turns a flat region into a lit form from a ramp and a light direction; contact_shadow, outline_smart and dither_band finish it. A brightness filter moves every pixel off the palette. These move pixels along the ramp, so the art still uses exactly the colours it was given.

Animation you can check

An agent cannot watch a GIF play, and the faults that matter are invisible in a still frame.

  • offset_cels moves a cel along a line or an arc across any number of frames in one Aseprite launch, rounding the running position so the spacing reads as speed rather than as a limp.
  • apply_timing_curve gives a cycle a shape in time (hold_extremes, attack, eased, flat). A hold is a duration, never a duplicated frame.
  • validate_loop measures the result: per-frame hashes, spacing, contact rows, durations, and the faults it finds, including a wrap frame that repeats the first and a contact edge that drifts.
  • link_cels / unlink_cels expose Aseprite's linked cels, which is how a held pose is actually drawn.

Selections, and Minecraft resource packs

Six selection tools scope later edits to a region, kept in a .msk sidecar so a selection survives between calls. Four Minecraft tools export a sprite into a resource pack at its correct texture path, validate it against the game's rules, and write both .mcmeta forms.

Works with any MCP client

The mcp 2.x SDK, ASEPRITE_MCP_TRANSPORT for stdio / streamable-http / sse, and schemas shaped for strict function-calling clients: every parameter carries a concrete type, with no nullable anyOf and no pydantic title noise. See docs/CLIENTS.md.

Fixed, and worth reading before upgrading

  • Concurrent edits destroyed sprite files. Eight trials of two overlapping edits to one sprite: three kept only one edit, five left a file that no longer decoded, and all eight reported success. Invocations now hold a lock; the same trials keep both edits 8 of 8.
  • Caller text could forge the result protocol. A layer name containing U+2028 could claim to be the server's own result or error, and the poisoned sprite stayed poisoned. Sentinels are now per-run and framed with a nonce generated after the arguments are serialized.
  • A filled ellipse was a different shape from its own outline (behaviour change: every filled ellipse has different pixels now, and discs are rounder).
  • Unknown arguments are rejected instead of silently dropped, run_cli no longer reports refused work as success, and exports no longer echo values they clamped.

Upgrading

Two behaviour changes: filled ellipses are redrawn as described above, and concurrent calls to one sprite are serialized rather than racing. Everything else is additive.

The full changelog has all forty entries.

v0.7.0 - Godot SpriteFrames and slice metadata exports

Choose a tag to compare

@MalloyTheDev MalloyTheDev released this 13 Jun 06:41

v0.7.0 adds the first engine-ready export layer — take a sprite all the way to game-engine resources.

Highlights

  • New Godot 4 SpriteFrames export preset (export_godot_spriteframes) — .tres + packed sheet, one animation per Aseprite tag, timed from frame durations.
  • New generic slice metadata export (export_slice_metadata).
  • Engine-agnostic slice schema: aseprite_mcp.slice_metadata.v1.
  • Hitbox / hurtbox / collision / interact / pivot / origin / attach / spawn / nine_slice typing.
  • Slice type detection from JSON user-data, then name convention, then custom fallback (never errors).
  • No-clobber export behaviour preserved (overwrite=True to replace).
  • Includes prior unreleased hardening: release gate, Hypothesis property tests, DoS size limits, SECURITY.md, CI build + Python 3.10–3.13 matrix.

110 tools. Full notes: https://github.com/MalloyTheDev/aseprite-mcp/blob/main/CHANGELOG.md

Upgrading

No breaking API changes. New tools only; existing behaviour unchanged.

v0.6.1 - no-clobber output hardening

Choose a tag to compare

@MalloyTheDev MalloyTheDev released this 13 Jun 00:27

v0.6.1 is a safety hardening patch release.

Highlights

  • Output-writing tools are now no-clobber by default. create_sprite, save_sprite_as, export_png, export_gif, export_spritesheet, and export_game_asset_bundle refuse to overwrite an existing file.
  • Existing files require explicit overwrite=True to be replaced.
  • Multi-file exports validate planned outputs before writing — a sprite sheet (+ JSON) or asset bundle fails before writing anything if any target already exists.
  • Workspace symlink-escape behaviour now has regression coverage (pure-Python test, always run in CI).
  • CI now uses least-privilege contents: read permissions.

Sprite saves raise WorkspaceError on a name conflict; exports raise ExportError. Tool count unchanged (108).

Upgrading

Existing scripts that intentionally re-write the same output path must now pass overwrite=True. No other API changes.

Full changelog: https://github.com/MalloyTheDev/aseprite-mcp/blob/main/CHANGELOG.md

v0.6.0 - atomic batch operations

Choose a tag to compare

@MalloyTheDev MalloyTheDev released this 12 Jun 23:48

Aseprite MCP v0.6.0 — atomic batch operations

v0.6.0 changes the product story from "the agent calls many Aseprite tools one at a time" to "the agent sends one validated edit plan → Aseprite opens once → all edits apply atomically → save once." 108 tools.

Added — apply_operations

Apply a list of edits to one sprite in a single Aseprite process, atomically.

  • 21 curated mutating ops — layers, frames, tags, drawing, slices, replace_color.
  • One process per batch — open once → all ops in one app.transaction → save once.
  • dry_run — validates the op list with zero Aseprite launches.
  • Atomic fail-fast — any failure rolls back, saves nothing, and names the failing op index.
  • Typed ValidationFailed / LuaToolError boundaries; returns workflow_manifest.v1 (kind batch).
  • Tests cover carried state across ops, pixel-parity vs individual calls, rollback, and dry-run safety.

Also in this release — internal hardening (since v0.5.0)

  • Typed error hierarchy (AsepriteMCPError + specific subclasses; AsepriteError alias kept).
  • Typed value models (Point/Rect/ColorSpec/Pixel/…) at the validation boundary.
  • core/ vs MCP-tool split — reusable logic in aseprite_mcp.core, importable without the MCP app; back-compat import shims preserved.

Install

git clone https://github.com/MalloyTheDev/aseprite-mcp.git
cd aseprite-mcp && uv sync
claude mcp add aseprite -- uv --directory /ABSOLUTE/PATH/TO/aseprite-mcp run aseprite-mcp

Full notes: CHANGELOG.md. Independent project — not affiliated with Aseprite / Igara Studio S.A.