pixel-mcp
# pixel-mcp
An MCP server that lets an LLM create and edit pixel art reliably. Implements
[SPEC.md](SPEC.md): indexed-color documents (8x8 to 256x256), layers, frames,
symmetry, grid-text editing, server-side PNG/GIF rendering, reference
quantization, and advisory quality linting.
Plain JavaScript with TypeScript-checked JSDoc types (`tsc --checkJs`, strict).
No native dependencies.
## Setup
```sh
pnpm install
pnpm check # typecheck (tsc, strict, noEmit)
pnpm test # node:test suite, includes an in-memory MCP end-to-end test
pnpm start # run the server on stdio
```
Claude Desktop / Claude Code style config:
```json
{
"mcpServers": {
"pixel": {
"command": "node",
"args": ["c:/path/to/pixel-mcp/src/index.js"]
}
}
}
```
## Persistence
Documents survive restarts. Everything is written through to disk on every
successful edit, and loaded on boot:
```
<dataDir>/
documents/<docId>/
document.json # size, palette, layers, frames, symmetry, tile mode, stamps, selections
cels/<frame>__<layer>.bin # raw palette-index buffers
preview.png # composited frame 0, human-viewable
refs/<refId>.png # loaded reference images
```
`dataDir` defaults to the platform data directory (`%LOCALAPPDATA%\pixel-mcp`
on Windows, `~/Library/Application Support/pixel-mcp` on macOS,
`$XDG_DATA_HOME/pixel-mcp` elsewhere). Override with `--data-dir <path>` or
`PIXEL_MCP_DATA_DIR`. Undo/redo stacks and checkpoints are in-memory only,
like a normal editor.
`PIXEL_MCP_EXPORT_SCALE` sets the default `export` scale factor for the project
(built-in 4). Set it to `1` for native-resolution delivery — game textures an
engine samples want no upscaling — so the model needn't pass `scale` on every
export. A per-call `scale` argument still overrides it.
## Tool surface (~60 tools)
Every tool that ingests a PNG accepts exactly one of `png_base64` or `path`.
Paths are read from the server filesystem; relative paths resolve against the
server process's working directory. Base64 remains useful for remote clients.
- **Lifecycle** — `create_document`, `open_document` (PNG import + quantize),
`export` (png / gif / aseprite_json — writes a real file to a caller path or
`<dataDir>/exports/` and returns the path; `return_base64` for the bytes;
default scale from `PIXEL_MCP_EXPORT_SCALE`),
`split_sprite_sheet` (regular PNG grid from base64 or a server-local path to
separate documents),
`pack_sprite_sheet` (document frames to a transparent PNG sheet with layout
metadata),
`resize_canvas`, `undo`, `redo`, `describe`, `list_documents`,
`delete_document`, `set_symmetry`, `set_tile_mode`, `set_active_layer`,
`set_active_frame`, `checkpoint`
- **Palette** — `set_palette_color`, `add_palette_color`,
`remove_palette_color` (remaps pixels), `swap_palette_indices`,
`generate_ramp` (cool shadows / warm highlights), `replace_color`,
`mix_palette_colors` (blend two entries into a third), `adjust_palette_color`
(HSL nudge in place or as a new variant), `extract_palette` (median-cut a PNG
into hex colors + a swatch, no document — feeds `create_document`)
- **Drawing** — `set_pixels`, `line` (pixel-perfect `no_doubles`), `rect`,
`ellipse`, `flood_fill`, `outline`, `paste_grid`
- **Shading & texture** — `shade` (ramp-step or nearest-snap darken/lighten),
`dither_gradient` (ordered-dither blend between two indices), `scatter`
(seeded grain/noise from an index set), `import_image` (drop an external PNG
onto a layer or store it as a stamp)
- **Regions** — `copy_region` / `cut_region` (named stamps), `paste_stamp`
(flip/rotate; ignores symmetry by default so placed content is never
silently mirror-corrupted), `move_region`, `mirror_region`, `shift_layer`,
`clear_region`, `save_selection`
- **Layers** — `add_layer`, `remove_layer`, `rename_layer`, `reorder_layer`,
`set_layer_visibility`, `set_layer_lock`, `merge_down`
- **Viewing** — `view`, `view_text`, `view_diff` (vs. checkpoint),
`view_window` (zoomed patch + context thumbnail), `view_tiled` (NxN repeat
with seams marked — the perception tool for seamless textures)
- **References** — `load_reference`, `quantize_reference`, `view_reference`
- **Animation** — `add_frame`, `remove_frame`, `reorder_frame`,
`set_frame_duration`, `copy_cel`, `view_frames` (onion skin; the model's
perception tool for motion), `preview_gif` (human-facing output — clients
show a GIF as a single still in model context)
- **Lint** — `lint` with all ten spec rules (orphans, doubles, banding,
pillow_shading, unused_colors, near_duplicates, broken_outline,
symmetry_drift, stray_alpha, tile_seams) plus persistent waivers via
`lint_waive` / `lint_unwaive`: judged-and-accepted findings stay suppressed
on every later pass. `doubles` flags rhythm breaks in diagonal staircases,
not every 2-over-1 step, so ellipses don't light up; `symmetry_drift` reports
one finding per contiguous asymmetric region; `tile_seams` only fires when a
tile mode is set, so ordinary sprites stay quiet.
Every editing tool returns the changed bounding box plus a render of the
changed region (4px context) and a whole-canvas thumbnail; pass
`render: false` to skip. All edits are atomic — a failed call leaves the
document untouched — and undoable (depth 100).
## Grid text
One character per pixel, one line per row: `.` = transparent, `1`-`9` map to
palette indices 1-9, `a`-`z` to 10-35, `A`-`Z` to 36-61, `#` = 62, `@` = 63.
(`0` parses as transparent.) Digit characters equal their palette index,
matching the spec's example; the spec's prose lists one more character than
there are slots, which this resolves.
```
grid 8x8 palette=4
.111111.
11111111
11211211
11111111
12111121
11222211
11111111
.111111.
legend: 1=yellow #f8e030 2=black #202020
```
Workflow tip that earned its place in live testing: for a planned sequence of
edits, pass `render: false` on each call and batch-verify with `view` at
milestones. Use `view_text` for pixel-exact ground truth and `view` for
gestalt — the two channels together are the point.
## Spec deviations and gap-fills
- `set_symmetry`, `set_active_frame`, `save_selection`, `list_documents`,
`delete_document` are added; the spec references the underlying state but
defines no tool to manage it.
- `open_document` and `load_reference` accept PNG only; Aseprite binary import
is not implemented (export to Aseprite JSON is).
- `quantize_reference` with `palette: 'auto'` appends the extracted colors to
the document palette so the returned indices are immediately valid.
- Persistence (not in the spec) as described above.
## Development
Manual end-to-end smoke that writes renders to `scripts/smoke-out/` for
eyeballing: `node scripts/smoke.mjs`.
TDQS
Scored across 70 tools
With 70 tools, some overlap is inevitable, but each tool has a detailed description that clarifies its distinct purpose. For example, the various view tools (view, view_text, view_window, etc.) serve specific viewing needs. A few tools like copy_region and cut_region could be confused, but overall disambiguation is strong.
All tools follow a consistent snake_case naming pattern, typically verb_noun (e.g., set_tile_mode, add_layer, reorder_frame). Even single-word verbs like view and undo fit the pattern. No mixing of conventions like camelCase or inconsistent verb forms.
70 tools is very high for an MCP server, far beyond the typical 3-15 range. While the domain of pixel art editing is complex, this count feels excessive and could overwhelm agents. Consolidating some tools (e.g., merging palette editing tools) would improve usability without losing functionality.
The tool surface covers the full workflow of pixel art creation: document management, layers, frames, palette editing, drawing primitives, region operations, stamps, selections, linting, and export. There are no obvious gaps for basic to intermediate pixel art tasks.