MA

malloythedev/aseprite-mcp

Developer tools
21 stars 0 forks Quality 55 Trend 31

A Model Context Protocol server that drives Aseprite headlessly and measures what it drew: 156 tools to draw, shade and animate pixel art, and results that say what each call did.

Overview

      Drawn, shaded, animated, timed and then measured : entirely through MCP tool calls, with no manual pixel-pushing. Every image here is regenerated by a script in this repository. An extensive Model Context Protocol server that lets an AI agent (Claude Code, Claude Desktop, or any MCP client) : draw pixel art, build animations, manage layers/frames/tags/palettes, and export to PNG/GIF/sprite sheets, all headlessly. 📖 docs/TOOLS.md: every tool with its parameters. It works by generating and running them through Aseprite's batch mode (aseprite -b --script ...), plus the Aseprite CLI for exports. Every operation opens a real .aseprite file, edits it, and saves, so your files stay fully editable in the Aseprite GUI.

README

Aseprite MCP

 

 

 

Drawn, shaded, animated, timed and then measured: entirely through MCP tool calls, with no manual pixel-pushing. Every image here is regenerated by a script in this repository.

An extensive Model Context Protocol server that lets an AI agent (Claude Code, Claude Desktop, or any MCP client) create and edit Aseprite sprites: draw pixel art, build animations, manage layers/frames/tags/palettes, and export to PNG/GIF/sprite sheets, all headlessly.

📖 Full tool reference: docs/TOOLS.md: every tool with its parameters.

It works by generating Lua scripts and running them through Aseprite’s batch mode (aseprite -b --script ...), plus the Aseprite CLI for exports. Every operation opens a real .aseprite file, edits it, and saves, so your files stay fully editable in the Aseprite GUI.

  • 165 tools across every part of a sprite: drawing (pixel-perfect and anti-aliased), custom brushes and symmetry, ramp-aware shading, selections that scope later edits, palettes, layers, frames, cels, animation tags, slices and 9-patch, effects, text, tilemaps, transforms, and export (per-layer, per-tag, sprite sheets, GIF, onion-skin, Godot, Minecraft). See the tool catalogue or the full reference.
  • Whole assets in one call - workflow tools scaffold and validate a character, an icon set, a tileset or an eight-direction walk template, and a batch op-runner applies hundreds of edits atomically in a single Aseprite process.
  • 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 (with an optional second, weaker fill light so the shadow side stays readable), gradient_map brings imported or filtered art onto a ramp in the first place, and specular_highlight, contact_shadow, cast_shadow, glow, 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. A glow of blended alpha or a shadow of multiplied alpha would break that, which is why both are built of ramp steps arranged in space instead.
  • Indexed mode that draws - create_sprite gives an indexed sprite a real 33-entry palette (a transparent entry at index 0, then Aseprite’s 32 defaults) rather than the 256 identical blacks that made every colour equidistant, so every draw resolved to index 0 and vanished while reporting success. set_color_mode quantizes the palette from the art instead of mapping onto whatever the file happened to carry, and refuses a conversion that would make drawn pixels disappear.
  • Work you can check - assess_sprite measures the drawing itself: pixels with no neighbour of their own colour, jagged diagonals, how much of the canvas the art fills, whether it is centred, and what fraction of it sits exactly on a declared ramp. An agent cannot look at its own sprite, so every fault comes back as a line saying what it is and which tool puts it right, and remove_stray_pixels is the one that answers for noise.
  • Edits you can check - diff_sprites compares two frames and says what changed, in buckets that mean different things: pixels that entered or left the silhouette, pixels repainted inside it, and pixels that changed only their alpha. An edit that silently went nowhere, to the wrong layer, inside a stale selection, off the canvas, looks exactly like an edit that was not needed, so “nothing changed” is reported loudly rather than quietly. Pass expect= to turn the measurement into a check.
  • Animation you can check - offset_cels moves a cel along a line or an arc across any number of frames in one launch, with the spacing distributed so it reads as speed rather than as a limp; tween_cels does the other three things an inbetween does, scaling, turning and fading one drawn cel across frames, with the anchor on the contact edge so a squash lands on the ground; smear_frame draws one frame’s subject along its own movement, stepping down a declared ramp so the trail stays on the palette; apply_timing_curve gives the cycle a shape in time by setting durations, never by duplicating a frame; and validate_loop measures the result and names the faults a still frame hides: a wrap frame repeating the first, a pose held by duplication, placeholder timing, a contact edge that drifts.
  • Calls you can check - the three bullets above measure the artwork; this one measures the call. A tool that did something other than what it was asked for is the failure this server keeps finding, and it is always quiet, so the result says what happened rather than that it happened. A write whose selection excluded every pixel reports pixels_outside_selection rather than a bare ok. shade_region_by_light and specular_highlight report region_components and region_bounds, because base_color scopes by colour distance and two materials’ ramps are routinely closer than the default tolerance: every step of a gold ramp is within it of a step of a brass one, so a pass can land somewhere its caller never looked. generate_ramp reports distinct and, when lightness clamping has collapsed two steps onto one colour, the light_range that would not have. dither_band names the two colours its 1-based steps resolved to. Every one of those exists because its absence cost a debugging session, and the showcase generators are where they were spent. Pixel art is authored, not rendered, and the server now takes it that way: draw_pixel_map accepts the same character grid get_pixels(format="map") emits, so per-pixel intent can be written down and read back. A caller handed only a thousand-entry coordinate list does not write a thousand considered pixels, it writes a formula that emits them, and a formula produces the smooth monotone surfaces that make generated pixel art look generated. The shape is uniform rather than nearly uniform: every result that describes a sprite carries ok, which thirty of them did not until the key moved into the one function they all return through, and a workflow manifest reports the same pixel counters at its top level as a bare call does, so one check reads both.
  • Sandboxed file access: by default the file capability is scoped to the workspace (relative paths only; absolute/.. paths rejected unless you opt in).
  • No-clobber by default: output-writing tools refuse to overwrite an existing file; pass overwrite=True to replace it intentionally.
  • Structured results: every editing tool returns JSON describing the updated sprite, and every pixel-writing one returns how many pixels it wrote, clipped, skipped, or lost to an active selection.
  • render_preview returns a PNG so the agent can see its work and self-correct.
  • Deterministic, stateless, robust: each call is an isolated, headless Aseprite run.

Contents

Showcase What the tools produce, with the numbers behind it: a lit scene, an equipment sheet, per-cel ordering, palette reduction
Requirements · Install · Configuration Getting it running
Register with an MCP client Claude Code, Claude Desktop, Cursor, Codex, Continue, Zed, Goose (full guide)
High-level workflows · Batch operations Whole assets in one call; many edits in one process
Tool catalogue All 165 tools by domain (full reference)
Live viewing · Example agent workflow Watching edits land; an end-to-end run
How it works · Security Architecture, the sandbox, and what is enforced
Notes & limitations · Troubleshooting Honest edges, and what to do when something breaks

Showcase

Every image here was produced entirely through MCP tool calls, and every number under them is real output from the same run.

Shade a form, not a filter

Flat fill, then shade_region_by_light, then outline_smart. The dither_band call below runs and changes almost nothing here: on a 24px disc with a five-step ramp the chosen pair has no terminator wide enough for a two-pixel Bayer cell to sit in, so the third panel differs from the second by its outline and five pixels. This caption used to credit it anyway.

create_sprite("orb.aseprite", 32, 32)
ramp = generate_ramp("#5a7fd4", steps=5, hue_shift=-40, saturation_shift=-18)["colors"]
draw_ellipse_in_box("orb.aseprite", 4, 4, 24, 24, ramp[2], filled=True)
shade_region_by_light("orb.aseprite", ramp, light_angle=125, light_z=0.55, rim=0.25)
dither_band("orb.aseprite", ramp, from_step=1, to_step=2, pattern="bayer4", width=2)
outline_smart("orb.aseprite", ramp, darken_steps=2, light_angle=125)

A brightness filter would have moved every pixel off the palette. These tools move pixels along the ramp, so the result still uses exactly the five colours it was given: the shading is made of the palette rather than applied on top of it.

Animate it, then measure the animation

One offset_cels call for the arc, one for the shadow, one apply_timing_curve for the timing, then validate_loop to check it.

frames = list(range(1, 13))
offset_cels("throw.aseprite", "ball",   frames, dx=46, arc_height=20)   # the arc
offset_cels("throw.aseprite", "shadow", frames, dx=46)                  # straight along the ground
apply_timing_curve("throw.aseprite", curve="hold_extremes", base_ms=70)
validate_loop("throw.aseprite", layer="ball", loops=False)
spacing      8.06  6.4  6.4  5.0  4.03  4.0  4.03  5.0  6.4  6.4  8.06
contact_row    32   25   20   16    13   12    12   13   16   20   25   32
durations     175   70   70   70    70   70   175   70   70   70   70   70
passed: true

An agent cannot watch a GIF play. It can read that the spacing is symmetric about the apex, that the ball slows as it rises and speeds as it falls, and that the extremes are held two and a half times as long as the passing frames.

Reorder one cel, not the layer stack

The same two layers in the same order, on two frames. Only the cel’s z-index differs.

set_cel_z_index("swing.aseprite", "sword", frame=2, z=0)   # blade behind the shield
set_cel_z_index("swing.aseprite", "sword", frame=3, z=2)   # blade in front, same layer

A blade that crosses a shield is behind it on the approach and in front of it on the follow-through, and a layer stack cannot say that: a layer’s position applies to every frame at once. Moving the layer changes the other frames; duplicating the sword onto a second layer means hiding one of the two per frame and keeping two drawings in step forever.

z is an offset on the layer’s own stack position, so 0 means “wherever this layer sits” and a cel one layer down needs only z=1 to draw in front of the one above it. The result reports the frame’s competing cels back to front, because a number alone does not tell you the arm moved:

frame 2  z=0  renders: sword then shield   pixel (45,20) is #5c7a3a, the shield's oak
frame 3  z=2  renders: shield then sword   pixel (45,20) is #eef1f5, the blade's steel

That pixel is the whole claim, and the generator asserts it: a z-index that round-tripped through the file while changing nothing about the render would produce an identical-looking strip. The blade is drawn long enough to show its tip on the far side, because “behind” only reads as occlusion when you can see both ends of the thing being occluded. Note also that z is refused outside -32768 to 32767, because the editor accepts a larger number in memory and then stores it in a 16-bit field, so saving and reopening turned 32768 into -32768.

Turn a blend into pixel art

117 colours, then 13, then 5. The banding is the palette doing its work, not a filter over the top.

quantize_palette("dusk.aseprite", max_colors=14)            # derive a palette from the art
set_color_mode("dusk.aseprite", "indexed", palette_source="keep")   # then map the art onto it

extract_palette reports the colours an image already uses and set_color_mode maps art onto a palette that exists; quantize_palette is the step in between, deriving a small palette from the artwork.

It is two calls on purpose, and the first one touches no pixels. Quantizing derives the palette and says so:

max_colors=14   palette holds 14, art has 117, 8 of them exactly
    warning: 109 of the 117 colours in the art are not in the derived palette and will be
             approximated by their nearest entry when the sprite is converted to indexed.
converted to indexed   3888 drawn pixels, verified=True, now 13 colours

verified=True is the conversion confirming it did not lose a single drawn pixel, which is the one thing a mode change must never do quietly. max_colors is also a ceiling with a cliff in it: a budget equal to the number of colours in the art is where the reduction collapses, and four colours at max_colors=4 come back as one averaged grey while max_colors=5 returns all four. The result says which happened rather than reporting a clean reduction either way.

Light a room, not a sprite

112 by 72 pixels, five ramps, four frames. Every pixel is a ramp entry: nothing here is alpha, a blur, or a blend.

Three things in that picture are worth more than the picture.

Thirty-five blocks, one shading call. shade_region_by_light builds a distance field over everything matching one base colour, and a field over a mask with thirty-five disconnected parts describes each part on its own. The mortar joints are what make the mask thirty-five components instead of one slab, so the whole wall takes its form from a single call:

shade_region_by_light("dungeon.aseprite", STONE, base_color=STONE[8],
                      light_angle=155, bulge=0.75, tolerance=1.0, layer="wall")

An earlier version of this section claimed that and left out what it cost. The claim is only worth anything if the blocks come out different, and with every block 17 pixels wide they did not: a distance field over two components of the same size is the same field twice, so same-sized blocks are shaded identically and differ only in how far the falloff pushed them. Measured by normalising value away, so two blocks count as the same when their pixels rank the same whatever their brightness, 8 of 24 sampled blocks came back pixel for pixel identical at 6 tones, all in the dim zone where shift_along_ramp had clamped their darks together. The far wall was one tile repeated.

Two changes fixed it, and the generator now asserts both. Block widths cycle through (17, 11, 23, 14, 20) with a per-course phase, so the fields differ; and the ramps are thirteen steps rather than nine, so a darkened block still has somewhere to go:

wall: 35 blocks, 31 distinct shading patterns, 28 of them unique, every block at 4 tones or more

The falloff is zones down a ramp. No tool here lights a scene from a point, and faking one by blending would land every pixel between palette entries. shift_along_ramp moves a rectangle one or two steps down the surface’s own ramp instead, which is how a pixel artist builds a falloff. The trick is hiding the seams, and that is why it is a rectangle per course rather than one band across the room: the only cut that does not show is one along a joint, and because the courses are staggered no single column is a joint on all of them.

falloff (one rectangle per course per zone)
  wall   45 blocks, 12 runs darkened, 6 blocks off-value
  floor  22 blocks,  8 runs darkened, 4 blocks off-value

floor row 56: step 8 at x=20, step 3 at x=95

That last line is asserted by the generator, because a falloff nobody measured is a falloff you have to take on trust. At one step a zone the assertion still passed and the room still looked evenly lit, which is a measurement being satisfied by something no reader can see. The reverse cost more: that same assertion went on passing while the far wall was a repeating tile, because nothing was measuring that. An assertion covers the failure it was written for and no other, which is why there are now three of them on this one picture.

The flicker is four frames with four durations. set_all_frame_durations would give an even pulse, and an even pulse reads as a machine rather than as fire. The flame also leans a different way and stands a different height on each frame, and the generator asserts all four silhouettes differ:

flame     rows  lean   ms  halo
  frame 1   14  +0.00   90  115 pixels in 3 rings
  frame 2   12  +0.18   70  103 pixels in 3 rings
  frame 3   15  -0.14  110  122 pixels in 3 rings
  frame 4   12  +0.10   80  116 pixels in 3 rings

The torchlight is the wall, brightened. This is the part that took the longest to get right, and the answer turned out not to be a tool. Three drafts used glow, whose rings are ramp steps rather than alpha, and all three read as a sticker. glow grows its rings outward from the subject’s silhouette, so a tall narrow flame gives a tall narrow egg with a hard elliptical boundary, and it paints at full opacity, so inside it the mortar joints and the block shading vanish into a smooth oval. Light brightens a surface. It does not erase it.

So the pool is the masonry moved up its own ramp, by however many steps the distance from the flame allows:

lift = round(POOL_GAIN * (1.0 - reach / POOL_RADIUS) ** 2)
target = min(step + lift, len(STONE) - 1)

Every pixel on that wall is already an exact ramp entry, so finding its step is a table lookup and lifting it is arithmetic on an integer. One get_pixels over the pool’s bounding box, one draw_pixels back:

light pool: lifted 1503 wall pixels up their own ramp, 0 off-ramp and left alone

The joints lift too, which is exactly why the masonry survives inside the light. glow is still there, at radius=3, for the heat right at the flame, which is the job it is good at.

The sconce casts onto the wall it is bolted to, with light_angle=90 and a high light_height so the shadow falls straight down and short. It had none for three drafts, which is the single commonest reason a lit object reads as pasted on.

The floor recedes. Its courses grow taller and its stones grow wider down the frame, because flat equal courses read as a second wall lying down:

floor: course heights [5, 6, 7, 8], stone widths [17, 21, 26, 31], down the frame

Where it meets the wall there is a shorter, darker skirting course and then three rows of flagstone stepped down their own ramp, rather than the four flat full-width rows an earlier version put there. That did stop two surfaces of grey blocks reading as one wall, the way a letterbox bar stops a bad crop.

The chest’s shadow is cast_shadow with ground_layer="floor", which clips it to the flagstones so it cannot run off the floor and hang in the air, and it uses the floor’s ramp rather than the chest’s, because a shadow is a darkening of what it lies on. The chest’s own contact shadow had to move: contact_shadow takes the occluder’s colour, and run after the brass pass that had already repainted the straps, only 3 pixels of the chest were still the colour it was given. It runs before that pass now and reports 102 darkened wood pixels, which the generator asserts is not zero.

Animate a palette, not the pixels

Every other animation here moves something. This one moves nothing: there is one drawn frame, and the lava flows because its indices rotate. cycle_palette generates the timeline from that single frame, and the twenty-four frames of the loop contain no new drawing at all.

The technique constrains the art rather than the other way round, and lava.py records the three drafts it took to learn how. A rotation can only translate a pattern that is monotone in index space, so the lava has to be painted as a sawtooth of consecutive indices laid across the direction of travel; a triangle would reflect instead of flowing. The run has to close, because a one-way dark to white ramp puts a bright-to-black seam at every wrap, which reads as a moving staircase. And it has to be long and weighted: with eight entries the bright one recurs every eight pixels, which is a bright line every eight pixels however the brightnesses are arranged, so the fall came out as a drill thread. This run is twenty-four entries of which twelve are crust that barely differ, built by three ramp_between calls joined at shared ends, and what travels is the crack.

The direction is a sign, and not the one you would reason to. step=1 puts frame two’s row y where frame one’s row y - 1 was, so the pattern travels down and the cataract falls. The first draft had it running uphill.

The cataract and the pool drift differently, and both come out of the same call: the fall is banded by row and the pool across the flow, so one rotation moves one down and the other sideways. Nothing in the generator says “move this down and that right”.

The stone is the control, and the generator holds it to that: it reads the same rock window on all twenty-four frames and requires it unchanged, which is what separates “the colours rotated” from “the picture was redrawn”. It also checks the flow by comparing two frames through a shifted window, which is how the backwards step was caught. Aseprite’s Lua API exposes no per-frame palette (measured on 1.3.18.6 and written down in cycle_palette’s own docstring), so the rotation is applied to the pixels’ own indices and the result says which mechanism ran: method: "pixel_remap". On an indexed sprite the two are the same picture, and the move never routes a colour through a nearest-entry match, so the palette comes back byte for byte.

Scaffold a one-shot, and let the timing do the hitting

A one-shot is where a scaffolded animation goes wrong in three ways at once: the frame count is a guess, the frames are numbered instead of named, and the whole thing is tagged as a loop with uniform timing. scaffold_cycle(kind="attack") settles all three before a pixel is drawn, and attack.py is the drawing laid over what it built.

The timing is the part worth looking at, and it is not the timing you would guess. The curve puts the 24-millisecond frame on the swing, not on the impact:

phase duration why
anticipation 240 ms the wind-up, held long enough to read as intent
swing 24 ms one frame you barely see, which is what makes it a snap
impact 200 ms held, because the hit has to land rather than flash past
recoil 80 ms
recover 80 ms

That ordering is the craft: the fast frame is the blur between poses, and the pose that has to be understood is the one that holds. Uniform timing at 80 ms would run the same five drawings in the same order and read as a shove. The generator pins the snap to frame two for exactly that reason, because “the shortest frame is the impact” is the plausible wrong answer and a test that only checked for non-uniformity would accept it.

Each phase gets a tag named for the pose (attack_anticipation through attack_recover, plus attack over the whole cycle), and every one is written with repeats=1 rather than left at 0, which means “play forever” in the file format. That field is what validate_loop reads, so a one-shot tagged as a loop makes the checker report a duplicated seam frame on an animation that has no seam. The per-phase tags matter as much as the whole-cycle one, since a single-frame tag left looping says “hold this pose forever”.

validate_loop also found an art bug here, which is the kind of thing this server is for: it reported the drawn content’s bottom row moving 4px across the phases, because the recover blade was swinging clean through the floor. Fixing the arc did not end it either, since the centreline then cleared the ground and the blade’s own thickness still did not. The generator checks that directly now, because a background was added and the contact measurement stopped being able to say so.

Make fast movement read as speed, in colours the sprite already had

Two frames of something moving quickly are two frames of it being in two places, which the eye reads as teleportation. A smear is the frame an animator draws to fix that, and smear_frame takes the movement vector from the cels themselves, so there is nothing to keep in sync with the drawing. Top to bottom: the movement as it arrives, the stretch smear that elongates the subject back along its path, and the echo smear that draws it several times.

The trail is made of colours the sprite already had. Each pixel of it is the nearest ramp entry to the subject’s own colour there, stepped toward the dark end and clamped, so the smear never invents a colour and palette_conformance stays at 1.0. smear.py asserts that directly: every colour in the trail is on the ramp it was given, the trail lies between the two positions, and the subject’s own pixels are untouched because the trail goes behind it.

This piece is indexed on purpose, because smear_frame was the one ramp-taking tool with no reading of what its ramp becomes on a palette: it resolves the ramp in Python into a lookup table and never passes a ramp to Lua, so the harness that measures that had nothing to see. The reading exists now, and building this piece immediately taught three things the docstring does not say.

A stretch trail asks for one shift level per ramp entry, so it is as long as the ramp, and it measures its room by where the subject’s lightest colour sits. Two drafts went into lengthening the ramp and shortening the movement before the message was read properly: what fixed it was giving the sphere a specular highlight, so a pixel finally sat on the ramp’s top step. A highlight turned out to be load-bearing.

And on a shaded subject the reading then fires anyway, in both modes, because the darker pixels run out of ramp before the trail ends and clamp at the dark entry. That is not avoidable by drawing better: the only subject that cannot clamp is one painted in a single colour. The reading used to blame the palette for it and suggest adding colours, while its own numbers said the palette held every target exactly (#226). It now tells the two apart, a clamp being copies that wanted one colour and a short palette copies that wanted several, and gives each its own remedy: for a stretch, mode='echo'. The generator asserts on the sentence as well as on trail_on_palette’s counts, and the “crushed palette” case it builds turned out to be clamps too: the two colours there that share a palette entry belong to different parts of the subject, so no copy merges because of the palette.

Scaffold a whole asset in one call

create_rpg_item_sheet lays out a named slice per item and make_8_direction_walk_template generates the frames and one animation tag per direction; both are then drawn into with the drawing and shading tools. Each part carries its own ramp, so the sword’s steel, brass, leather and gem are shaded independently, and the generator asserts per cell that no colour from one item reached another.

validate_sprite_for_game_export -> passed  (width, height, color_mode, min_frames, required_tags)
export_game_asset_bundle        -> hero.png, hero.gif, hero_sheet.png (+JSON), hero_idle.gif, manifest.json

sword   17 colours, from 4 ramps and nothing else
shield  19 colours, from 3 ramps and nothing else
helm    12 colours, from 2 ramps and nothing else
key      9 colours, from 1 ramp and nothing else
scroll  14 colours, from 3 ramps and nothing else

sword: blade is rows 0 to 26, 68% of its height
centres: sword=19.5  shield=19.0  helm=19.0  key=19.5  scroll=18.5   spread 1.0

Those checks earn their keep, and each of them was added after the thing it checks had already gone wrong.

The colour census: base_color scopes a shading pass by colour distance, and at the default tolerance of 24 every step of a gold ramp is within reach of a step of a brass one, so a pass meant for one item also matched art in a different cell and reshaded it. Nothing looked wrong, and three rounds went into redrawing the wrong thing before the colours were counted. That was #183, and it is fixed: both tools report region_components and region_bounds now, so a pass scoped to a 27px coin that comes back with a box spanning 200 pixels of canvas says so in its own result. The census here stays, because a script asserting what it needs is cheaper to read than one trusting that someone else asserted it.

The blade’s share: a draft of this sword was 54% blade at a 1 to 2.3 aspect, which is a spearhead, and at inventory size it read as a crystal shard on a stick. 54% is not something anybody notices as a number, which is the whole reason for printing it.

The centre line: five items at five different heights read as five sprites from five games.

A fourth check, not shown here, asserts each glint’s pixels are inside the part named for it. Two of the four used to be wrong: scoped to a whole 40px cell, specular_highlight built its normals from the entire compound object, and the sword’s glint landed on its leather grip while the blade had none. It is worth knowing that check has a hole in it too, because the key passed it while still being wrong: it proves a glint is on the right part, not on the right side of it.


Requirements

  • Aseprite 1.3+ (the scripting API). The Steam and standalone builds both work.
  • Python 3.10+
  • uv (recommended), or any PEP 517 installer.

Install

git clone https://github.com/MalloyTheDev/aseprite-mcp.git
cd aseprite-mcp
uv sync

That creates a virtual environment and installs the aseprite-mcp package and its dependencies. The test suite comes in two tiers: the pure-Python tests always run, and the ones that drive a real Aseprite are opt-in behind --run-aseprite (and skip, saying so, when the flag is given and Aseprite cannot be found):

uv run pytest                  # pure-Python tests; no Aseprite needed
uv run pytest --run-aseprite   # plus the integration and golden-output tests

Configuration

Everything is configurable via environment variables (all optional):

Variable Purpose Default
ASEPRITE_PATH Full path to Aseprite.exe / aseprite. Auto-detected (Steam, standalone, PATH).
ASEPRITE_MCP_WORKSPACE Folder where relative sprite paths are resolved. /workspace from a source checkout, otherwise a per-user data directory.
ASEPRITE_MCP_TIMEOUT Per-operation timeout in seconds (clamped to 1-3600). 90
ASEPRITE_MCP_ALLOW_ABSOLUTE Allow absolute / workspace-escaping paths (1/true to enable). off (sandboxed)
ASEPRITE_MCP_TRANSPORT stdio, streamable-http, or sse. See the warning below before using an HTTP transport. stdio

stdio is the right default: it is what desktop MCP clients launch, and it keeps the server reachable only by the process that started it. The HTTP transports exist for agents that cannot spawn a local process. Treat them as a deliberate exposure decision: this server’s file access is scoped by a workspace directory, not by an identity, so binding it to a reachable port gives every caller that can reach it the same filesystem access the local user has. Bind to loopback, or put authentication in front of it.

On this machine Aseprite was detected at C:\Program Files (x86)\Steam\steamapps\common\Aseprite\Aseprite.exe, so ASEPRITE_PATH is not strictly required, but setting it explicitly is the most reliable.


Register with an MCP client

Using something other than Claude? docs/CLIENTS.md has a copy-paste config block per client format (Codex CLI’s TOML, Continue’s config.yaml, Zed’s context_servers, Goose’s extensions, and the mcpServers JSON that Claude Desktop, Cursor, Cline, Roo Code, Windsurf and LM Studio share), the config file path for each one, the launch form that avoids the Windows venv lock, and the strict-mode notes for OpenAI or Grok style function-calling bridges. The two quick starts below cover the common case.

Replace /ABSOLUTE/PATH/TO/aseprite-mcp below with the absolute path to your clone.

Claude Code (CLI)

claude mcp add aseprite -- uv --directory /ABSOLUTE/PATH/TO/aseprite-mcp run aseprite-mcp

To set the Aseprite path explicitly (recommended if auto-detection fails):

claude mcp add aseprite \
  --env ASEPRITE_PATH="/path/to/Aseprite.exe" \
  -- uv --directory /ABSOLUTE/PATH/TO/aseprite-mcp run aseprite-mcp

Claude Desktop / generic MCP client (JSON)

Add this to your client’s MCP server config (e.g. claude_desktop_config.json). A ready-to-copy template lives in mcp-config.example.json (Windows-style paths shown; adjust for your OS):

{
  "mcpServers": {
    "aseprite": {
      "command": "uv",
      "args": ["--directory", "C:\\path\\to\\aseprite-mcp", "run", "aseprite-mcp"],
      "env": {
        "ASEPRITE_PATH": "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Aseprite\\Aseprite.exe",
        "ASEPRITE_MCP_WORKSPACE": "C:\\path\\to\\aseprite-mcp\\workspace"
      }
    }
  }
}

Restart the client; the aseprite server and its full tool set will be available (the current count is at the top of docs/TOOLS.md). Ask the agent to run health_check to confirm Aseprite is wired up correctly, and see docs/CLIENTS.md for what the answer should look like.


High-level workflows

Beyond the low-level tools, a few workflow tools scaffold a whole asset in one call and return a manifest (created files, paths, frames, tags, dimensions, and suggested next steps) so an agent can keep going. They compose the low-level tools: deterministic scaffolding, no AI generation.

Tool Description
create_character_sprite Transparent canvas + body/details layers + a generated shading ramp + an outlined placeholder.
make_4_frame_idle_animation Turn a 1-frame sprite into a 4-frame idle “bob” loop with a tag.
scaffold_cycle Scaffold a named cycle (walk, run, idle, attack, hurt, death) at its conventional frame count, one tag per phase (walk_contactL, walk_downL, …), durations shaped by a timing curve rather than uniform, and loop or one-shot set correctly: attack, hurt and death are one-shots.
create_tileset_project Canvas + tilemap layer + a starter tileset (grass/dirt/water/stone, or your own).
create_icon_set Grid sheet of icon cells, each a placeholder inside a named slice (icon_0, …).
create_rpg_item_sheet Grid sheet with a named slice per item (sword/shield/potion/…).
make_8_direction_walk_template 8-direction walk template: frames + one tag per direction (N/NE/E/…).
export_game_asset_bundle PNG + animated GIF + sprite sheet (+ JSON) + per-tag GIFs + manifest.json. Assesses every frame first and refuses on a measured defect (an absent keyline, or with ramp, a pixel off it) unless allow_defects=True; every other reading is listed in the manifest’s assessment.
export_godot_spriteframes Godot 4 SpriteFrames resource (.tres) + packed sheet; one animation per tag, timed from frame durations.
export_slice_metadata Engine-agnostic _slices.json: hitbox/hurtbox/collision/attach/9-slice/pivot from slice names or JSON data.
validate_sprite_for_game_export Check a sprite is game-ready (dimensions/tile multiple, colour mode, frames, required tags, transparency, palette budget, exports exist) → pass/fail report.
validate_asset_spec / plan_asset_spec / build_asset_from_spec Describe an asset once (aseprite_mcp.asset_spec.v1), then validate it, dry-run the plan, or build it (structure only, canvas/layers/frames/tags/slices/palette + exports; you draw the art).
validate_asset_against_spec Closes the loop: does the sprite that got built actually match the spec it was built from. Canvas, layer names, frame count, tags, slices, palette capacity. Reports what it checked as well as what failed, so “nothing was wrong” and “nothing was checked” cannot be confused.

“Make me an idle-animated hero and a game-ready bundle.”

  1. create_character_sprite("hero", 32, 32, base_color="#3878c8") builds layers, palette and a placeholder.
  2. Draw the character on the body / details layers (low-level tools).
  3. make_4_frame_idle_animation("hero.aseprite") makes a 4-frame loop tagged idle.
  4. validate_sprite_for_game_export("hero.aseprite", expected_width=32, required_tags=["idle"]) confirms it is game-ready.
  5. export_game_asset_bundle("hero.aseprite", scale=8) judges every frame, then writes PNG/GIF/sheet+JSON/manifest into hero_bundle/. A defect refuses the bundle with the frames and the fix named; the manifest’s assessment lists the readings worth a look that do not block it.

Bring art in from elsewhere

Art made outside the server (a PixelPrep conversion, a downloaded sheet, a screenshot) comes in through two tools, and the rest of the toolset then works on it as on anything drawn here.

  1. import_image("scene.png", "scene.aseprite") for one picture. A PNG with an alpha channel, which is what PixelPrep writes, arrives as an ordinary layer; an opaque one without alpha, or an opaque indexed PNG, arrives as a Background, which Aseprite draws opaque everywhere.
  2. import_spritesheet("walk.aseprite", "walk_strip.png", 32, 48) for a strip, a column (layout="vertical") or a grid (layout="grid") of frames. A cell size that does not divide the sheet is refused before anything is launched, and spare grid cells come back as empty_frames.
  3. set_color_mode("scene.aseprite", "indexed") to limit the palette. An opaque scene keeps every pixel: one of its colours can land on the transparent index (in both scenes tried while building this it was the darkest), which a Background shows as a colour, and the readers and drawing tools treat it as one.
  4. assess_sprite and render_preview to look at it (a large scene previews at a scale that keeps the image within 2048 px), then edit it like any other sprite.
  5. For animation, export_motion_trail shows every frame of a motion in one image, so arcs and spacing can be judged before anything is exported.

Workflow manifest contract

Every workflow tool returns a standardized workflow_manifest.v1 object (defined in core/manifest.py) so the asset layer stays consistent as it grows. Always present: ok, schema_version, kind, created_files[], suggested_next_actions[], warnings[]. Included when relevant: sprite{}, exports[], palette{}, animation{}, tilemap{}, tiling{}, validation{}, operations[], plan[] and dry_run. File/export entries are {role, path, format, metadata_path?}.

A manifest also reports what it wrote, at the top level beside ok: pixels_written, pixels_clipped, pixels_skipped, pixels_outside_selection, selection_applied and linked_frames_also_changed. Top level rather than in a pixels section of their own, because the sections above each describe the product while these are a verdict on the call, and because every other result in this server reports them there: one rule reads them across all 165 tools. They are absent rather than zero, so a key that is present at all means there is something to read, and a tool that writes no pixels grows no 0 that reads as a claim about pixels. apply_operations used to drop them, which let a batch report status: applied for an op whose every pixel an active selection had refused.

{
  "ok": true,
  "schema_version": "workflow_manifest.v1",
  "kind": "character_sprite",
  "sprite": { "path": "...", "width": 32, "height": 32, "color_mode": "rgb",
              "frames": 1, "layers": ["body", "details"], "tags": [] },
  "created_files": [ { "role": "source_sprite", "path": "...", "format": "aseprite" } ],
  "palette": { "colors": ["#1b1f2a", "..."], "count": 5 },
  "pixels_written": 437,
  "suggested_next_actions": ["Draw the character on the 'body' layer", "..."],
  "warnings": []
}

Batch operations

apply_operations applies a list of edits to one sprite in a single Aseprite process, atomically: open once → run every op inside one transaction → save only if all succeed. This collapses multi-launch agent flows (add layer → draw → add frame → tag) into one fast, all-or-nothing call. Pass dry_run=true to validate the op list without launching Aseprite.

apply_operations("hero.aseprite", [
  {"op": "add_layer",      "args": {"name": "fg"}},
  {"op": "fill_layer",     "args": {"layer": "fg", "color": "#1d2b53"}},
  {"op": "draw_rectangle", "args": {"layer": "fg", "x": 2, "y": 2, "width": 12, "height": 8, "color": "#ff004d"}},
  {"op": "add_frame",      "args": {"copy_from": 1, "duration_ms": 120}},
  {"op": "add_tag",        "args": {"name": "idle", "from": 1, "to": 2}}
])

If any op fails, the whole batch rolls back and the error names the failing op index. v1 ops: layer add/rename/visible/opacity/remove · frame add/duplicate/remove/duration/all-durations · cel position/opacity/copy/delete · tag add/remove · draw set_pixel/pixels/line/rectangle/fill_rectangle/ellipse/fill_ellipse/fill_layer/clear_layer · slice add/remove · replace_color.


Tool catalogue

Relative filenames resolve inside the workspace (absolute paths require ASEPRITE_MCP_ALLOW_ABSOLUTE=1; see Security). Frames and palette-aware operations are 1-based for frames, 0-based for palette indices. Colours accept #RRGGBB, #RRGGBBAA, r,g,b, r,g,b,a, index:N, or a name (black, white, red, green, blue, yellow, cyan, magenta, transparent, …).

Sprite lifecycle

Tool Description
create_sprite Create & save a new sprite (rgb/indexed/gray, optional background; indexed starts with a usable palette).
save_sprite_as Save a copy under a new path (optionally flattened).
set_color_mode Convert between rgb / indexed / gray (with dithering); builds the palette from the art and refuses to drop pixels.
resize_canvas Change canvas size without scaling art (top_left / center).
crop_sprite Crop the canvas to a rectangle.
scale_sprite Scale the whole sprite (by factor or to dimensions; nearest/bilinear).
flatten_sprite Flatten all layers into one.
trim_sprite Auto-crop the canvas to non-transparent content.
convert_layer_to_background · convert_background_to_layer Toggle the opaque Background layer.

Inspection & preview

Tool Description
get_sprite_info Full structured state: size, mode, frames, layer tree, tags, palette.
render_preview Render a frame to a PNG image you can view, scaled up for small sprites; a large one is fitted to 2048 px on its longer side. A frame the sprite does not have is refused. views shows it several ways side by side from the same render: color, value (greys of each colour’s own lightness, for whether the forms read without hue), silhouette (black on white, for whether the shape reads and its parts have air between them) and actual (1x).
get_pixels Read the pixel colours of a region, composited or from one named layer, as rows or as a compact symbol map (≤ 64×64 per call).
assess_sprite Measure the drawing: colours and ramps, noise, jagged diagonals, how much of the canvas is used, centring, symmetry, palette conformance against a declared ramp, and tile seams. Each measurement worth acting on comes back with a line saying why.
diff_sprites Compare two frames pixel for pixel: pixels added to or removed from the silhouette, repainted inside it, or changed in alpha alone, plus the colours involved and the box they sit in. Says so loudly when nothing changed, and takes expect= to turn the measurement into a pass or a fail.
list_sprites List sprite/image files in the workspace.

Layers

Tool Description
add_layer Add a layer (optional group, opacity, blend mode, visibility).
add_group_layer Add a group layer.
remove_layer · rename_layer Delete / rename a layer.
set_layer_properties Update opacity, blend mode, visibility, editability, name.
move_layer Reorder a layer (1-based stack index, 1 = bottom).
duplicate_layer · merge_layer_down Duplicate / merge a layer down.

Frames (animation)

Tool Description
add_frame Add a frame: empty at the end, or a copy right after the frame it copies.
duplicate_frame · remove_frame Duplicate / delete a frame.
set_frame_duration · set_all_frame_durations Set per-frame / uniform durations (ms).
reverse_frames · move_frame Turn a run of frames round, or move one frame to another position. Cels on every layer, durations and cel links all travel with the frame.

Cels (a layer’s image at a frame)

Tool Description
get_cel Inspect a cel (exists, position, bounds, opacity).
set_properties · get_properties Read / write custom properties on a sprite, layer, cel, tag, slice or tile, stored inside the .aseprite file so game metadata travels with the art instead of in a sidecar.
set_cel_position · set_cel_opacity Move / fade a cel.
copy_cel · delete_cel Copy a cel between frames / delete it.
link_cels · unlink_cels Share one image across frames (a held pose: editing any of them edits all, and the file stores it once) / give a frame its own copy back.
set_cel_z_index Reorder one cel against its layer’s neighbours, for a limb that is in front on one frame and behind on the next, without restructuring the layer stack.

Animation tags

Tool Description
add_tag Tag a frame range with a name, direction, colour, and a repeat count (0 means forever, which is how a cycle is marked).
set_tag · remove_tag Edit / delete a tag.

Animation (motion, timing, checks)

Tool Description
offset_cels Move a drawn cel along a line or an arc across frames in one launch, spacing distributed so it reads as speed rather than as a limp.
tween_cels Scale, turn and fade one drawn cel across frames in one launch: squash and stretch (the two axes go opposite ways), whole-degree rotation sampled nearest-neighbour so no colour is invented, and a fade that sets cel opacity rather than baking alpha. anchor="bottom" squashes onto the ground, which validate_loop’s contact check then confirms.
smear_frame Draw one frame’s subject along its own movement, so fast motion reads as speed rather than as teleportation. stretch elongates it and thins to a point; echo draws it several times. The vector comes from the cels themselves, and with a ramp every trail pixel is a colour the sprite already had.
squash_stretch_pass Squash or stretch the drawing on each frame while the edge it rests on stays put, preserving volume (what the anchored axis loses, the other gains). Where tween_cels resamples one drawing across frames, this deforms every frame’s own art, which is how an animation that already exists gets its weight. Nearest neighbour only, and it reports what ran off the canvas.
apply_timing_curve Give a cycle a shape in time (hold_extremes, attack, ease_in, ease_out, flat), or hold and snap named poses. Sets durations and never duplicates a frame.
validate_loop Measure a cycle instead of playing it: per-frame hashes, spacing, contact rows, durations, and the faults a still frame hides (a wrap frame repeating the first, duplicated poses, placeholder timing, a drifting contact edge).

Easing belongs in one of these at a time. Eased spacing plus an eased duration curve is the same curve applied twice, and reads as slow motion; apply_timing_curve measures the cels and says so rather than doing it quietly.

Drawing

Tool Description
draw_pixels Plot individual pixels (per-pixel or shared colour).
draw_pixel_map Draw from a character grid, one character per pixel, with a legend of symbol to colour. The write side of get_pixels(format="map") and the same shape it returns, so a read, an edit and a write round-trip. A ragged map is refused rather than padded, and a character the legend does not define is refused rather than skipped: a typo in a grid would otherwise paint nothing and report success.
draw_line · draw_polyline Line / connected segments; pixel_perfect & antialias options.
draw_curve Quadratic Bézier curve.
draw_rectangle · draw_ellipse Outline or filled rectangle / ellipse; ellipse has antialias.
draw_ellipse_in_box An ellipse filling the same bounding box draw_rectangle takes, so an even diameter is possible and a circle can line up with a rectangle.
fill_area Flood fill (paint bucket) from a point.
fill_layer · clear_layer Fill the whole cel / erase it to transparent.

Brushes & symmetry

Tool Description
draw_brush Stamp a custom brush shape (ASCII mask) at many points.
stamp_pattern Tile an image/sprite across a region (with spacing, opacity, blend).
mirror_layer Reflect one half of a layer onto the other (build symmetric art).
draw_symmetric_pixels Plot pixels with horizontal/vertical/4-way mirroring.

Shading & light

Tool Description
shade_region_by_light Shade a flat region as a lit form, from a ramp and a light direction.
shade_facets Shade a form built from flat planes, one tone per plane, from a map whose legend names directions instead of colours. For anything hard: a crate, a helm, cut stone. shade_region_by_light reads its normals from the silhouette’s distance field, so it rounds a form over and a block comes out a pillow.
gradient_map Put every pixel on a ramp by its brightness, whatever it started as: the tool that brings imported or filtered art onto a palette at all. Optional ordered dithering.
shift_along_ramp Move pixels along a colour ramp, keeping every one of them on the palette.
specular_highlight Place a small glint where the light reflects at the viewer, never on an edge pixel. The ramp’s top step, or a brighter colour for metal.
contact_shadow Darken the pixels where one form meets another, along its ramp.
seam_occlusion Darken the seam where two masses of the same material overlap, from a character map whose legend names the masses and says which is nearer the viewer. contact_shadow finds its occluder by colour, so it cannot see an arm against a torso cut from one stone. Falls off with distance, so the contact does not read as drawn brickwork.
surface_emission Light an interior source onto the surface around it, up that surface’s own ramp by distance. glow builds its halo on a layer below the art, so a core inside a silhouette gets nothing; this warms the stone instead, and two tones at one distance move together so the form survives.
outline_smart Outline a shape in colours taken from its own ramp, not one flat colour.
dither_band Dither the boundary between two adjacent ramp steps, widening the transition.

Selections

Tool Description
select_region Select a rectangle, ellipse, or polygon (replace/add/subtract/intersect).
select_by_color Magic wand: select every pixel matching a colour.
modify_selection Grow, shrink, or outline the current selection.
invert_selection · deselect Swap selected for unselected / clear it.
get_selection Report whether there is a selection and what it covers.

A selection is not stored in the .aseprite file, and every call here is its own Aseprite process, so it is kept in a .msk sidecar beside the sprite and loaded again on the next call. It scopes every later edit until deselect, which is how an image editor behaves, and every result that touched pixels reports selection_applied.

“Scopes every later edit” is now enforced rather than asserted. The mask is consulted in one function that every write goes through, and a test holds each write path to it separately, asserting the pixels outside the selection rather than a count. That is there because nine of them used to bypass it: the anti-aliased coverage write, the flood fill, every per-pixel effect pass, mirror_layer and the batch runner’s replace op all edited the whole layer while the result still said selection_applied: true. A count can be satisfied by a tool that wrote the right number of pixels in the wrong places.

Slices (named regions / 9-patch)

Tool Description
add_slice · set_slice · remove_slice · list_slices Manage slices with optional 9-patch center, pivot, colour, and data.

Effects & colour adjustments

Tool Description
fill_gradient Linear/radial gradient, multi-stop, optional Bayer dithering.
fill_checkerboard Two-colour checkerboard pattern.
add_outline Pixel outline around art (outside/inside, 4/8-connectivity, thickness). With a light angle the weight tapers across the terminator, 2 then 1 then 0 over a band of edge, instead of switching per pixel and breaking the keyline into scraps.
add_drop_shadow Hard drop shadow on a new layer beneath the art.
cast_shadow A shadow on the ground, away from the light and foreshortened by its height, built of ramp steps and clipped to the surface it falls on.
glow A halo of several rings, each a step further down a ramp, optionally dithered at the outer edge. Its own layer, so deleting it removes the effect, and one cel a frame, so one layer carries an animated one.
replace_color Swap a colour (with per-channel tolerance).
remove_stray_pixels Replace pixels with no neighbour of their own colour by the colour around them: the dirt a shading pass leaves, without touching a dither or introducing a colour.
normalize_edge_runs Even out the run lengths along a silhouette’s diagonals, which is the wander that reads as programmer art. Merges a stray one-row run into its neighbour and reports the jagged-corner count before and after; it only ever adds a pixel, so the extent cannot move, and it declines a 1:1 diagonal, a spike, a notch or a hairline by name.
invert_colors Invert RGB (alpha preserved).
adjust_brightness_contrast · adjust_hue_saturation · desaturate Colour grading.
apply_color_curve Remap every tone through a curve, all three channels or one. Brightness moves every tone alike and contrast pivots them around the middle; a curve lifts the shadows while the highlights stay put. It cannot add a colour or any transparency, so it is safe on finished art.

Text

Tool Description
draw_text Render text (built-in bitmap font or a TrueType file) as crisp pixels.

Tilemaps (Aseprite 1.3+)

Tool Description
create_tilemap_layer Create a tilemap layer with a tile size + empty grid.
add_tile · fill_tile · paint_tile_pixels Define/draw the tileset artwork.
set_tile · set_tiles · fill_tilemap Place tiles on the grid.
get_tilemap Read the grid of tile indices.

Image stamping

Tool Description
stamp_file Composite another image/sprite file onto a layer (opacity, blend mode).
draw_image_base64 Composite an inline base64 image onto a layer.

Palette

Tool Description
get_palette · set_palette Read / replace the whole palette.
set_palette_color · add_palette_color · resize_palette Edit individual entries / size.
load_palette Load a palette from .gpl/.pal/.png/.aseprite.
set_transparent_color Set the transparent index (indexed sprites).
extract_palette Collect the unique colours used in a sprite/image.
sort_palette Sort by hue/luminance/saturation/value (remaps indexed pixels).
quantize_palette Derive a palette from the artwork itself, reduced to at most N colours.
list_palette_usage Which palette indices the art actually draws with, as runs of consecutive entries, so the run worth cycling is visible before you cycle it. Indexed sprites only, because an RGB pixel carries no index.
cycle_palette Animate an indexed sprite by rotating a run of palette indices across new frames, the classic palette-cycling effect for water, fire and glows. Aseprite’s Lua API exposes no per-frame palette, so the rotation is applied to the pixels’ own indices and the result says so (method: "pixel_remap").
generate_ramp Build a hue-shifted shading ramp from a base colour. Says how many steps came back distinct, since lightness clamps at both ends.
ramp_between Build a ramp from its two ends, the cool shadow and the warm highlight, interpolated in Oklab so the middle is a blend rather than a hue rotation.
ramp_from_art Recover the ramp a sprite is already painted with, ordered dark to light, with the share of the art each step covers.

Palette cycling is the one animation technique here that moves no pixels: rotate a run of indices and water flows, lava creeps, a portal turns. list_palette_usage is the step before it, because the run worth cycling is a property of the art rather than of the palette: an index the drawing never uses contributes nothing, and a run of two is a flicker rather than a flow. One measured limitation shapes the tool: Aseprite’s Lua API exposes no per-frame palette, so the rotation is applied to the pixels’ own indices instead and the result says which it did (method: "pixel_remap"). On an indexed sprite the two are the same picture, and the move is exact because it never resolves a colour to a nearest entry.

Transform & export

Tool Description
flip_sprite · rotate_sprite Flip (h/v) / rotate (90/180/270) the whole sprite.
export_png Export one frame as a flattened PNG (scaled).
export_gif Export the animation as an animated GIF.
export_tag_gif Export only a named tag’s frames as a GIF.
export_spritesheet Pack frames into a sheet (+ JSON metadata; layer/tag filters & splits).
export_spritesheet_packed Pack frames with extrude (the one-pixel border that stops texture bleeding in a game engine), merged duplicates and trim.
export_frames Export each frame to its own file ({frame} pattern).
export_layer · export_layers Export one layer / each layer to separate files.
export_tags Export each animation tag’s frames to separate files.
export_onion_skin Render a frame with neighbouring frames ghosted behind it.
export_motion_trail Every frame of a motion in one image, oldest faintest, so arcs and spacing can be judged at a glance.
import_image Build an editable .aseprite from a flat image.
import_spritesheet Slice a sprite sheet image into frames, one per cell: a strip, a column or a grid.

Minecraft resource packs

Tool Description
export_minecraft_texture Export a sprite into a resource pack at its correct texture path.
validate_minecraft_texture Check a sprite against Minecraft’s texture rules before it ships.
write_pack_mcmeta Write pack.mcmeta, the file that makes a directory a resource pack.
write_texture_mcmeta Write the .png.mcmeta sidecar for an animated texture.

Reference / rotoscope

Tool Description
add_reference_layer Add a dimmed, locked layer holding a reference image to trace over.
import_reference_sequence Import a sequence of images as per-frame references (rotoscoping).

Batch operations

Tool Description
apply_operations Apply many edits to one sprite atomically in a single process (dry_run to validate only). See Batch operations.

GUI companion mode

Tool Description
open_in_editor Open a sprite in the live Aseprite GUI window (non-blocking) to watch edits.
gui_available Report whether the Aseprite GUI can be launched.

Health & self-test

Tool Description
health_check Self-test: Aseprite found?, version, workspace, tool count, and a real create+export round-trip.

Live viewing (GUI companion mode)

The server edits files headlessly, but you can watch the work in the real Aseprite window. Call open_in_editor("sprite.aseprite") and Aseprite opens it in a normal, non-blocking window. As the agent keeps saving edits with the other tools, Aseprite detects the on-disk change and offers to reload (or reloads automatically, depending on your Aseprite preferences), so edits appear without re-opening.

What this is and isn’t: Aseprite’s Lua scripting sandbox has no networking or timers, so the server can’t stream a live canvas into the GUI frame-by-frame. The companion view above (open once → headless edits → Aseprite reloads) is the robust, supported workflow. The agent’s own “eyes” remain render_preview, which returns a PNG it can inspect directly.


Example agent workflow

“Make me a 32×32 walking-slime animation.”

  1. create_sprite("slime.aseprite", 32, 32, "rgb")
  2. fill_layer the background, add_layer("slime.aseprite", "slime"), draw the body with draw_ellipse/fill_area/draw_pixels.
  3. add_frame("slime.aseprite", copy_from=1) a couple of times; nudge the body with set_cel_position to create a bounce.
  4. set_all_frame_durations("slime.aseprite", 120) and add_tag("slime.aseprite", "walk", 1, 3, "pingpong").
  5. render_preview to check it, iterate, then export_gif("walk.aseprite", "walk.gif", scale=8). Pass loop=False for a one-shot: an attack or a death is not a cycle, and a GIF declares that in its own metadata rather than in a caption.

How it works

client (any MCP client) ──MCP──> aseprite-mcp (MCPServer, Python)
                              │  builds a Lua body + ARG table
                              ▼
                         luagen.assemble_script  ──>  temp .lua
                              │   (prelude: JSON encoder, colour/pixel
                              │    helpers, drawing primitives, info)
                              ▼
                  Aseprite.exe -b --script temp.lua
                              │  prints  @@ASEMCP@@  (or @@ASEMCP_ERR@@)
                              ▼
                         runner parses the sentinel  ──>  dict back to the client

Drawing primitives (line, rectangle, ellipse, flood fill) are implemented deterministically in Lua and operate on a full-canvas copy of the target cel, so coordinates are always in sprite space and results are identical across runs. Exports use the Aseprite CLI’s native flags (--sheet, --scale, --data, …).

Project layout

src/aseprite_mcp/
  app.py          MCPServer instance, tool classification, portable schemas
  server.py       imports every tool module, picks the transport, main()
  core/           config (locate Aseprite, workspace, path sandbox), paths (output
                  paths + the no-clobber check), limits, errors, models, luagen
                  (Python->Lua + the shared PRELUDE), runner (run_lua / run_cli,
                  sentinel parsing), oplib (the batch op registry), manifest,
                  validation, asset_spec, minecraft, engines/ and slice_metadata
                  (engine export formats), ramps, indexed and lighting (colour
                  and light arithmetic), quality and spritediff (the measurements),
                  loopcheck, timing, frameops, motion and inbetween (animation maths)
  tools/          one module per domain: sprite, inspect, layers, frames, tags,
                  cels, animation, drawing, brushes, shading, selection, effects,
                  text, tilemap, image, palette, slices, transform, export,
                  export_presets, minecraft, reference, workflow, asset_spec,
                  batch, gui, health (+ common.py)
docs/TOOLS.md     full auto-generated tool reference, every registered tool
docs/CLIENTS.md   per-client setup, transports, and what to do when a client
                  mangles arguments
scripts/          gen_tool_docs.py (regenerates docs/TOOLS.md), quality_report.py,
                  release_gate.py, showcase/ (regenerates the images above)
tests/            pytest suite: the pure tests always run, the rest need
                  --run-aseprite

Security

This server hands an AI agent a file capability, so access is scoped by default:

  • Workspace-sandboxed paths. Relative filenames resolve under ASEPRITE_MCP_WORKSPACE, which defaults to /workspace from a source checkout and to a per-user data directory from an installed package, never to a path inside the Python installation. Absolute paths and paths that escape the workspace via .. (or a symlink that points outside it) are rejected unless you set ASEPRITE_MCP_ALLOW_ABSOLUTE=1.
  • No-clobber by default. Every output-writing tool (create_sprite, save_sprite_as, import_image, import_spritesheet, all export_*, export_game_asset_bundle) refuses to overwrite an existing file; pass overwrite=True to replace it on purpose. Multi-file exports validate every target up front, so they fail before writing anything if any target already exists. Pattern exports (frames/walk_{frame}.png) are expanded by Aseprite itself, so they are checked against everything the pattern could match.
  • Bounded work per call. A single call cannot exhaust the host: batch op-lists and pixel/tile/colour lists are capped, canvases are capped at 16384px per axis and 16,777,216 pixels of area (so two individually-legal axes can’t add up to gigabytes), inline base64 images at 32 MB, and text rasterization is budgeted while it renders. An outside image whose header declares more than the canvas caps is refused before it is opened, an export’s scale and a sheet’s padding are held so the rendered image stays inside them, and render_preview fits a large sprite to 2048 px. ASEPRITE_MCP_TIMEOUT is clamped to 1-3600s so it can’t be set to something that disables the timeout.
  • No shell, no injection. Aseprite is invoked with list-form arguments (never a shell), and every user value is passed into generated Lua through an escaped ARG table: user input is never concatenated into Lua source.
  • Bring your own Aseprite. The server only runs the Aseprite binary you point it at.

Run health_check to confirm the configuration (Aseprite path, workspace, sandbox state).

Notes & limitations

  • Each call is its own process. A tool call is an independent headless Aseprite run, so in-memory state (undo history, the “active” sprite) does not carry over, and operations take explicit coordinates rather than relying on what was open. The one piece of editor state that does survive is the selection: the selection tools keep it in a .msk sidecar beside the sprite and reload it on the next call, so it scopes later edits and deselect is a real step rather than a formality. A future live-GUI mode can layer on top of this without changing the tool API. docs/HEADLESS.md records what else batch mode takes away, which parts of the Aseprite API are unusable or unsafe under it, and why several tools are shaped the way they are.
  • Use a .aseprite/.ase extension to keep layers, frames, and tags editable. Saving to .png/.gif flattens.
  • get_pixels is capped at 4096 px (e.g. 64×64) per call; read in tiles for larger areas.
  • Anti-aliasing (antialias=True) only applies to RGB sprites; it’s ignored on indexed/gray. Tilemaps and reference layers require Aseprite 1.3+.
  • Grayscale sprites. A colour drawn on one becomes the grey Aseprite’s own set_color_mode("gray") would give it, so drawing and converting agree, and desaturate reaches the same grey. Green carries most of the light and blue almost none, so a colour’s grey is not the average of its channels: pure red, green and blue are three different tones, not one.
  • Indexed sprites and the Background. On an ordinary layer the transparent palette index means “no pixel”. On a Background Aseprite draws it as a colour, and so do get_pixels, assess_sprite, diff_sprites, the palette tools, trim_sprite, set_color_mode’s conversion check, the drawing tools, and every effect and shading tool that reads the layer it is about to write (replace_color, the filters, the shading masks, remove_stray_pixels, normalize_edge_runs, fill_gradient with respect_alpha). The same scene behaves the same as an RGB or an indexed Background. add_outline refuses a Background of either kind, since nothing on one is empty.
  • Fills start from Aseprite’s factory colours. Where a Background gains pixels (convert_layer_to_background, or growing its canvas with resize_canvas or crop_sprite) they are black, or the transparent index on an indexed sprite, whatever the editor’s colour bar holds. A headless run used to inherit that colour bar.

Troubleshooting

  • “Could not locate Aseprite.” Set ASEPRITE_PATH to the full executable path.
  • Nothing happens / permission denied on save. Ensure the workspace path is writable. Relative filenames go under ASEPRITE_MCP_WORKSPACE; health_check reports the directory files are actually landing in.
  • Timeouts on big operations: raise ASEPRITE_MCP_TIMEOUT (seconds).
  • A tool errors with a Lua message. The message is surfaced verbatim from Aseprite, it usually names the bad argument (e.g. a missing layer/frame).
  • Tests all skip. The Aseprite-driving tests are opt-in: run uv run pytest --run-aseprite. With the flag given and no Aseprite found they still skip, naming that as the reason, so set ASEPRITE_PATH as well.

Client-side problems (sprites landing in a venv directory, a text-only model that cannot see render_preview, the Windows lock on .venv\Scripts\aseprite-mcp.exe that blocks uv sync, a server that never appears) are covered in docs/CLIENTS.md.

Development

uv run pytest                              # pure-Python tests (no Aseprite needed)
uv run pytest --run-aseprite               # plus the integration and golden tests
uv run aseprite-mcp                        # run the server over stdio (manual debugging)
uv run python scripts/gen_tool_docs.py     # regenerate docs/TOOLS.md

See CONTRIBUTING.md for the architecture and how to add a tool, and CHANGELOG.md for release notes.

Contributing

Contributions are welcome! Please read CONTRIBUTING.md, add a test for your change, and regenerate the tool reference before opening a PR.

License

MIT © Brendan Malloy.

Disclaimer

This is an independent, unofficial project and is not affiliated with or endorsed by Aseprite or Igara Studio S.A. “Aseprite” is a trademark of its respective owner. You need your own licensed copy of Aseprite for this server to function.

View the full README on GitHub

Install

This server does not publish a one-line install command.

Open the repository installation guide New to MCP? Start with the mcpskill.org guide →

Configuration

{ "mcpServers": { "aseprite": { "command": "uv", "args": ["--directory", "C:\\path\\to\\aseprite-mcp", "run", "aseprite-mcp"], "env": { "ASEPRITE_PATH": "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Aseprite\\Aseprite.exe", "ASEPRITE_MCP_WORKSPACE": "C:\\path\\to\\aseprite-mcp\\workspace" } } } }