pixelate-mcp 0.3.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +114 -52
  3. package/index.js +519 -272
  4. package/package.json +33 -33
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 PixelAOI
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PixelAOI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,20 +1,19 @@
1
- # pixelate-mcp
1
+ # PixelAOI MCP
2
2
 
3
- An [MCP](https://modelcontextprotocol.io) server that lets an AI coding agent
4
- (Claude Code, Codex, Cursor) drive the **PixelAOI** editor — see the canvas,
5
- paint pixel art onto it, run editor commands, pixelate images locally, and
6
- build node-based blueprint graphs.
3
+ An [MCP](https://modelcontextprotocol.io) server that lets an AI agent (Claude
4
+ Code, Codex, Cursor, Claude Desktop) work in the **PixelAOI** pixel-art editor:
5
+ see the canvas, paint it, arrange layers, build animations, apply filters and
6
+ adjustment layers, pixelate images, and wire Blueprint node graphs.
7
7
 
8
8
  Everything runs on **your machine**: the server talks to the editor over a
9
- localhost-only bridge (`127.0.0.1`). No cloud, no accounts, no API keys.
9
+ localhost-only link (`127.0.0.1`). No cloud, no accounts, no API keys.
10
10
 
11
- ## 1. Enable the bridge in PixelAOI
11
+ ## 1. Turn on the agent link in PixelAOI
12
12
 
13
- Open PixelAOI and click the **agent** pill in the bottom status bar. It shows
14
- `A:WAIT` while listening and `A:LIVE` once an agent connects.
15
-
16
- (Alternatively, launch the editor with the `PIXELAOI_AGENT_BRIDGE=1`
17
- environment variable set.)
13
+ Click **agent off** in PixelAOI's status bar (bottom right). It turns to
14
+ **agent ready** while it waits, and **agent live** once your agent connects.
15
+ Click it again to turn it off. (Or start PixelAOI with
16
+ `PIXELAOI_AGENT_BRIDGE=1`.)
18
17
 
19
18
  ## 2. Add the server to your agent
20
19
 
@@ -33,62 +32,125 @@ args = ["-y", "pixelate-mcp"]
33
32
  ### Claude Desktop / Cursor (JSON config)
34
33
  ```json
35
34
  {
36
- "command": "npx",
37
- "args": ["-y", "pixelate-mcp"]
35
+ "mcpServers": {
36
+ "pixelaoi": { "command": "npx", "args": ["-y", "pixelate-mcp"] }
37
+ }
38
38
  }
39
39
  ```
40
40
 
41
- Restart the agent, make sure the editor is running with the bridge on, and
42
- ask it to draw something.
41
+ Restart the agent, make sure PixelAOI says **agent ready**, and ask it to
42
+ draw something. The server shows up as **PixelAOI**, with the PixelAOI mark
43
+ (in colours for light and dark clients), and tells the agent how to work with
44
+ the editor when it connects.
43
45
 
44
46
  ## Tools
45
47
 
46
- Canvas:
47
-
48
- - **`get_state`** — canvas size, frames, layers, tool, palette, blueprint
49
- status, and the list of runnable command names.
50
- - **`get_canvas_image`** — the composited current frame as a PNG.
51
- - **`run_command`** `{ action }` — run a headless command (`flip_h`,
52
- `rotate_90`, `trim`, `undo`, `save`, ...); names come from
53
- `get_state.commands`.
54
- - **`set_canvas_image`** `{ image_path | png_base64, as_new_document?, name? }`
55
- — paint an image. Matching canvas size paints onto the open document;
56
- otherwise it opens as a new one.
57
- - **`set_pixels`** `{ x, y, w, h, rgba_base64 }` — paint a raw RGBA block,
58
- clipped to the canvas.
59
- - **`pixelate_image`** — turn any image (path, base64, or URL) into
60
- grid-perfect pixel art using the editor's local engine (auto-detects the
61
- native grid and palette), then paint it in.
62
- - **`extrude_animate`** — bake a 3D-extrusion turntable of the current canvas
63
- across the timeline, one keyframe per frame.
64
-
65
- Blueprints (node graphs):
66
-
67
- - **`blueprint_node_kinds`** — the full catalog of addable node kinds.
68
- - **`blueprint_list`** — nodes, links, and value-wire bindings of the active
69
- graph.
70
- - **`blueprint_select_graph`** `{ graph }` — switch between Particle, Canvas,
71
- Ai, and Layer graphs.
72
- - **`blueprint_add_node`** / **`blueprint_delete_node`** — add or remove
73
- nodes.
74
- - **`blueprint_connect`** — wire node outputs into node inputs.
75
- - **`blueprint_connect_value`** — drive a node parameter with a value node.
76
- - **`blueprint_set_param`** / **`blueprint_set_data`** — set continuous
77
- params or discrete node config.
78
- - **`blueprint_bake`** — run the graph and bake the result to the canvas.
48
+ Each tool has a title your client can show, and says whether it only reads or
49
+ changes the document, so a client can let reads through and ask before
50
+ changes. Every change is one undo step in PixelAOI (`run_command` `undo` takes
51
+ it back).
52
+
53
+ | Tool | Title | |
54
+ |---|---|---|
55
+ | `get_state` | Editor state | reads |
56
+ | `get_canvas_image` | See the canvas | reads |
57
+ | `run_command` | Run a command | changes |
58
+ | `apply_filter` | Apply a filter | changes |
59
+ | `layers` | Arrange layers | changes |
60
+ | `adjustment_layer` | Adjustment layers | changes |
61
+ | `set_canvas_image` | Paint an image | changes |
62
+ | `set_pixels` | Paint pixels | changes |
63
+ | `pixelate_image` | Pixelate an image | changes (may fetch a URL) |
64
+ | `extrude_animate` | 3D turntable | changes |
65
+ | `add_frame` | Add a frame | adds |
66
+ | `select_frame` | Show a frame | adds |
67
+ | `set_frame_duration` | Frame timing | changes |
68
+ | `add_tag` | Tag frames | adds |
69
+ | `save_document` | Save the project | changes |
70
+ | `close_document` | Close the document | changes |
71
+ | `blueprint_node_kinds` | Blueprint node kinds | reads |
72
+ | `blueprint_list` | Read the Blueprint | reads |
73
+ | `blueprint_select_graph` | Switch Blueprint graph | adds |
74
+ | `blueprint_add_node` | Add a node | adds |
75
+ | `blueprint_connect` | Connect nodes | adds |
76
+ | `blueprint_connect_value` | Drive a parameter | adds |
77
+ | `blueprint_set_param` | Set a parameter | changes |
78
+ | `blueprint_set_data` | Set node data | changes |
79
+ | `blueprint_delete_node` | Delete a node | changes |
80
+ | `blueprint_bake` | Bake the Blueprint | changes |
81
+
82
+ In more detail:
83
+
84
+ - **`get_state`**: canvas size, frames, the layer stack (each layer's index and
85
+ uid, each folder's id), the selection, the tool, the palette, the Blueprint
86
+ editor's state, and the command names `run_command` takes.
87
+ - **`get_canvas_image`**: the current frame as PixelAOI shows it, as a PNG.
88
+ - **`run_command`** `{ action }`: a command by name (`flip_h`, `rotate_90`,
89
+ `trim`, `undo`, `save`, ...). A filter's name (`invert`, `blur`, ...)
90
+ applies it at once with its remembered settings.
91
+ - **`apply_filter`** `{ name, params?, output?, frames? }`: a filter on the
92
+ active layer as one undo step: `blur`, `hsl_adjust`, `levels`, `outline`,
93
+ `drop_shadow`, `color_reduce`, ... `params` by the labels PixelAOI shows
94
+ (`{"Radius": 3}`, `{"Hue": 30}`) or in order; `output` `pixels` (default),
95
+ `new_layer` or `adjustment` (an adjustment layer doing it live, with
96
+ `affects` `layer` or `below`); `frames` `this` (default), `selected` or
97
+ `all`. A selection limits it. Curves takes points per channel,
98
+ `{"all": [[input, output], ...], "red": ...}`, and Gradient Map a ramp,
99
+ `{"stops": [[position, r, g, b], ...]}` (0-255, at most 16 points or stops).
100
+ - **`adjustment_layer`** `{ action, kind?, params?, affects?, enabled?, item? }`:
101
+ layers with no art of their own that change what is under them, live: `add`,
102
+ `set`, `remove`, `bake`.
103
+ - **`layers`** `{ action, items?, … }`: `select`, `move`, `group`, `ungroup`,
104
+ `new_folder`, `set` (`visible`, `locked`, `opacity`, `blend`, `draft`,
105
+ `clipping`, `expanded`, `name`), `delete`, `duplicate`, `merge`. A layer by
106
+ its index, `{layer: i}` or `{uid: "…"}`; a folder by `{folder: id}`; no
107
+ `items` means the selection.
108
+ - **`set_canvas_image`** `{ image_path | png_base64, as_new_document?, name? }`:
109
+ paint an image; at the canvas's size it paints the open document, otherwise
110
+ it opens as a new one.
111
+ - **`set_pixels`** `{ x, y, w, h, rgba_base64 }`: a raw RGBA block, clipped to
112
+ the canvas.
113
+ - **`pixelate_image`**: any image (path, base64 or URL) into grid-perfect
114
+ pixel art with PixelAOI's own engine (it finds the native grid and palette),
115
+ then painted in.
116
+ - **`extrude_animate`**: a 3D extrusion turntable of the canvas across the
117
+ timeline.
118
+ - **`add_frame`**, **`select_frame`** `{ index }`, **`set_frame_duration`**
119
+ `{ index?, duration_ms }`, **`add_tag`** `{ name, from, to? }`: build an
120
+ animation (frames from 0; 10-10000 ms a frame).
121
+ - **`save_document`** `{ path }`: save as a `.pxl` project at an absolute path;
122
+ **`close_document`**: close the tab once saved (refused while unsaved).
123
+ - **Blueprints**: switch the graph (`Particle`, `Canvas`, `Ai`, `Layer`), list
124
+ the node kinds, add, wire (output to input, or a value node driving a
125
+ parameter), set parameters and node data, delete, and bake the result into
126
+ the document.
127
+
128
+ PixelAOI refuses changes while one of its dialogs or a filter is open, or while
129
+ you are drawing; the error says which, and the call can be made again after.
79
130
 
80
131
  ## Example
81
132
 
82
133
  > "Take ./hero.png, pixelate it, put it on the canvas, then bake a noise
83
134
  > texture behind it with a Canvas blueprint."
84
135
 
85
- The agent calls `pixelate_image` (local engine), then builds
86
- CanvasNoise -> CanvasOutput with the blueprint tools and runs
87
- `blueprint_bake`.
136
+ The agent calls `pixelate_image`, then builds CanvasNoise → CanvasOutput with
137
+ the Blueprint tools and runs `blueprint_bake`.
88
138
 
89
139
  ## Config
90
140
 
91
141
  - `PIXELAOI_BRIDGE_HOST` (default `127.0.0.1`)
92
142
  - `PIXELAOI_BRIDGE_PORT` (default `48653`)
93
143
 
144
+ ## If it can't connect
145
+
146
+ The error says so and how to fix it: open PixelAOI and click **agent off** so
147
+ it says **agent ready**. If the editor closes while the agent works, the next
148
+ call reconnects once it is back.
149
+
150
+ ## As a Claude Desktop extension
151
+
152
+ `manifest.json` and `icon.png` describe it as a Desktop Extension (its name,
153
+ icon, tools, and the editor's port as a setting). Build the `.mcpb` with
154
+ `npx @anthropic-ai/mcpb pack` in this folder, then open it with Claude Desktop.
155
+
94
156
  Get the editor at [pixelaoi.pro](https://pixelaoi.pro).
package/index.js CHANGED
@@ -1,13 +1,21 @@
1
1
  #!/usr/bin/env node
2
+ // PixelAOI's MCP server: lets an AI agent (Claude Code, Codex, Cursor, Claude
3
+ // Desktop) drive the PixelAOI editor running on this computer. It speaks MCP
4
+ // over stdio and reaches the editor over its agent bridge, a localhost-only
5
+ // TCP link (127.0.0.1:48653) that the user turns on from PixelAOI's status bar.
2
6
  import net from "node:net";
3
7
  import { readFile, writeFile } from "node:fs/promises";
4
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
10
  import { z } from "zod";
7
11
 
12
+ const VERSION = "0.6.0";
8
13
  const HOST = process.env.PIXELAOI_BRIDGE_HOST || "127.0.0.1";
9
14
  const PORT = Number(process.env.PIXELAOI_BRIDGE_PORT || 48653);
10
15
 
16
+ // ── The bridge ─────────────────────────────────────────────────────────────
17
+ // One JSON request per line ({id, op, args}), answered in order
18
+ // ({id, ok, result | error}).
11
19
 
12
20
  let sock = null;
13
21
  let connected = false;
@@ -24,7 +32,7 @@ function teardown(err) {
24
32
  buffer = "";
25
33
  for (const [, p] of pending) {
26
34
  clearTimeout(p.timer);
27
- p.reject(err || new Error("bridge disconnected"));
35
+ p.reject(err || new Error("the link to PixelAOI closed"));
28
36
  }
29
37
  pending.clear();
30
38
  }
@@ -34,18 +42,26 @@ function connect() {
34
42
  return new Promise((resolve, reject) => {
35
43
  const s = net.createConnection({ host: HOST, port: PORT });
36
44
  s.setNoDelay(true);
45
+ let opened = false;
37
46
  s.once("connect", () => {
47
+ opened = true;
38
48
  sock = s;
39
49
  connected = true;
40
50
  resolve();
41
51
  });
42
- s.once("error", (e) => {
52
+ // Every error is handled: before the link opens it says how to turn it
53
+ // on; after, the link is torn down (an unhandled one would end the server).
54
+ s.on("error", (e) => {
55
+ if (opened) {
56
+ teardown(new Error(`the link to PixelAOI broke (${e.code || e.message})`));
57
+ return;
58
+ }
43
59
  connected = false;
44
60
  reject(
45
61
  new Error(
46
- `cannot reach the PixelAOI editor bridge at ${HOST}:${PORT} — open the editor and ` +
47
- `click the "agent" pill in the bottom status bar to enable it ` +
48
- `(or launch with PIXELAOI_AGENT_BRIDGE=1). (${e.code || e.message})`,
62
+ `can't reach PixelAOI at ${HOST}:${PORT} (${e.code || e.message}). Open PixelAOI and click ` +
63
+ `"agent off" in its status bar: it turns to "agent ready", then "agent live" once this is connected. ` +
64
+ `(Or start PixelAOI with PIXELAOI_AGENT_BRIDGE=1.)`,
49
65
  ),
50
66
  );
51
67
  });
@@ -67,10 +83,10 @@ function connect() {
67
83
  pending.delete(msg.id);
68
84
  clearTimeout(p.timer);
69
85
  if (msg.ok) p.resolve(msg.result);
70
- else p.reject(new Error(msg.error || "bridge error"));
86
+ else p.reject(new Error(msg.error || "PixelAOI refused it"));
71
87
  }
72
88
  });
73
- s.on("close", () => teardown(new Error("bridge connection closed")));
89
+ s.on("close", () => teardown(new Error("the link to PixelAOI closed")));
74
90
  });
75
91
  }
76
92
 
@@ -80,7 +96,7 @@ async function call(op, args, timeoutMs = 30000) {
80
96
  return new Promise((resolve, reject) => {
81
97
  const timer = setTimeout(() => {
82
98
  pending.delete(id);
83
- reject(new Error(`bridge op '${op}' timed out after ${timeoutMs}ms`));
99
+ reject(new Error(`PixelAOI didn't answer '${op}' within ${timeoutMs / 1000}s`));
84
100
  }, timeoutMs);
85
101
  pending.set(id, { resolve, reject, timer });
86
102
  try {
@@ -93,374 +109,605 @@ async function call(op, args, timeoutMs = 30000) {
93
109
  });
94
110
  }
95
111
 
96
- const errText = (e) => ({
97
- content: [{ type: "text", text: `pixelaoi-mcp error: ${e?.message || e}` }],
98
- isError: true,
99
- });
112
+ const text = (t) => ({ content: [{ type: "text", text: t }] });
113
+ const json = (head, value) => text(head ? `${head}\n${JSON.stringify(value, null, 2)}` : JSON.stringify(value, null, 2));
100
114
  const stripDataUrl = (s) => {
101
115
  const i = s.indexOf(",");
102
116
  return s.startsWith("data:") && i !== -1 ? s.slice(i + 1) : s;
103
117
  };
104
118
 
105
- const server = new McpServer({ name: "pixelate-mcp", version: "0.3.1" });
119
+ // ── The server ─────────────────────────────────────────────────────────────
120
+
121
+ // The PixelAOI mark: Dark's colours for dark clients, Light's for light ones.
122
+ const ICON_DARK =
123
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAvUlEQVR42u3avQ3CMBCA0TPKIIxEQcUoUUrENBSwDSWbmAniE4qsIPy+AfLzJMvROeX8ftRodD+eSmwou35W7/sfYvAAAAAAAMDIpXvscqvNffQ6l7LnC2x9PksAAAAAAAAM3JTto6/Ls32Fed8XyJ5viWoeAAAAAAAAAEiSJMV35wLReW6f1fvcwacwAAAAAAAI5wI/e/7f+zvEEgAAAAAAAOYB/hO0BAAAAAAAgHnA/2UeYAkAAAAAAIDVPvCjNhDw4v7bAAAAAElFTkSuQmCC";
124
+ const ICON_LIGHT =
125
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAvklEQVR42u3avQ3CMBCA0TOih3lYJWV6RmAIepdswGDJBDABPqHICsLvGyA/T7IcnVNO9fqKRut8L7Gh7PpZve9/iMEDAAAAAAAjl+6x07w099FHPZc9X2Dr81kCAAAAAABg4I7ZPvq83NpXqPu+QPZ8UyzmAQAAAAAAAIAkSVJ8dy4Qnef2Wb3PHXwKAwAAAACAcC7ws+f/vb9DLAEAAAAAAGAe4D9BSwAAAAAAAJgH/F/mAZYAAAAAAAD42BuQGzL/MDRAkgAAAABJRU5ErkJggg==";
126
+
127
+ const INSTRUCTIONS = `PixelAOI is a pixel-art editor running on this computer; these tools drive it. They reach it over a local link the user turns on in PixelAOI: its status bar says "agent off" until clicked, "agent ready" while it waits, "agent live" once you are connected. If a call can't reach it, ask the user to click that pill.
128
+ - Start with get_state: the canvas size, the frames, the layer stack (with each layer's index and uid), the selection, the active tool and the command names run_command takes. Look with get_canvas_image before and after changes.
129
+ - Every change is one undo step in the editor, with a name; run_command "undo" takes the last one back.
130
+ - Frames count from 0. set_canvas_image and set_pixels paint the active layer of the frame on show; add_frame, select_frame, set_frame_duration and add_tag build animations.
131
+ - Blueprints (node graphs): blueprint_select_graph, blueprint_node_kinds, blueprint_add_node, blueprint_connect / blueprint_connect_value, blueprint_set_param / blueprint_set_data, then blueprint_bake.
132
+ - PixelAOI refuses changes while one of its dialogs or a filter is open, or while the user is drawing; the error says which. Ask the user to finish, then try again.`;
133
+
134
+ const server = new McpServer(
135
+ {
136
+ name: "pixelaoi",
137
+ title: "PixelAOI",
138
+ version: VERSION,
139
+ description:
140
+ "Drive the PixelAOI pixel-art editor: see the canvas, paint it, arrange layers, build animations, apply filters and adjustment layers, and wire Blueprint node graphs.",
141
+ websiteUrl: "https://pixelaoi.pro",
142
+ icons: [
143
+ { src: ICON_DARK, mimeType: "image/png", sizes: ["64x64"], theme: "dark" },
144
+ { src: ICON_LIGHT, mimeType: "image/png", sizes: ["64x64"], theme: "light" },
145
+ ],
146
+ },
147
+ { instructions: INSTRUCTIONS },
148
+ );
149
+
150
+ // What a client may show before running a tool: whether it only reads, or
151
+ // changes the document (every change is one undo step in PixelAOI), and
152
+ // whether running it twice does the same as once. Nothing here reaches
153
+ // beyond this computer, except pixelate_image given a URL.
154
+ const READS = { readOnlyHint: true, openWorldHint: false };
155
+ const ADDS = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
156
+ const CHANGES = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
157
+
158
+ function tool(name, { title, description, input, annotations }, run) {
159
+ server.registerTool(
160
+ name,
161
+ { title, description, inputSchema: input ?? {}, annotations: { title, ...annotations } },
162
+ async (args) => {
163
+ try {
164
+ return await run(args ?? {});
165
+ } catch (e) {
166
+ return { content: [{ type: "text", text: `PixelAOI: ${e?.message || e}` }], isError: true };
167
+ }
168
+ },
169
+ );
170
+ }
171
+
172
+ // ── Seeing ─────────────────────────────────────────────────────────────────
106
173
 
107
- server.tool(
174
+ tool(
108
175
  "get_state",
109
- "Get the PixelAOI editor state: canvas size, frame/layer counts, active tool, palette size, blueprint status, and the list of runnable command names.",
110
- {},
111
- async () => {
112
- try {
113
- const r = await call("state.get");
114
- return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
115
- } catch (e) {
116
- return errText(e);
117
- }
176
+ {
177
+ title: "Editor state",
178
+ description:
179
+ "What is open in PixelAOI: the canvas size, the frames and the one on show, the layer stack (layers and folders, top to bottom, with each layer's index and uid and each folder's id) and the selection, the active tool, the palette size, the Blueprint editor's state, and the command names run_command takes.",
180
+ annotations: READS,
118
181
  },
182
+ async () => json("", await call("state.get")),
119
183
  );
120
184
 
121
- server.tool(
185
+ tool(
122
186
  "get_canvas_image",
123
- "See the current canvas: returns the composited current frame as a PNG image.",
124
- {},
187
+ {
188
+ title: "See the canvas",
189
+ description: "The current frame as PixelAOI shows it (every visible layer together), as a PNG.",
190
+ annotations: READS,
191
+ },
125
192
  async () => {
126
- try {
127
- const r = await call("canvas.get");
128
- return {
129
- content: [
130
- { type: "text", text: `Canvas ${r.width}x${r.height}.` },
131
- { type: "image", data: r.png_base64, mimeType: "image/png" },
132
- ],
133
- };
134
- } catch (e) {
135
- return errText(e);
136
- }
193
+ const r = await call("canvas.get");
194
+ return {
195
+ content: [
196
+ { type: "text", text: `The canvas, ${r.width}×${r.height}.` },
197
+ { type: "image", data: r.png_base64, mimeType: "image/png" },
198
+ ],
199
+ };
137
200
  },
138
201
  );
139
202
 
140
- server.tool(
203
+ // ── Commands, filters, layers ──────────────────────────────────────────────
204
+
205
+ tool(
141
206
  "run_command",
142
- "Run a headless editor command by name (e.g. flip_h, flip_v, rotate_90, trim, undo, redo, save). Call get_state first and read its `commands` list for the valid names.",
143
- { action: z.string().describe("A command name from get_state.commands") },
207
+ {
208
+ title: "Run a command",
209
+ description:
210
+ "Run one of PixelAOI's commands by name: flip_h, flip_v, rotate_90, trim, undo, redo, save and the rest of get_state's commands list. A filter's name (invert, blur, ...) applies it at once with its remembered settings, as one undo step.",
211
+ input: { action: z.string().describe("A command name from get_state's commands.") },
212
+ annotations: CHANGES,
213
+ },
144
214
  async ({ action }) => {
145
- try {
146
- await call("command.run", { action });
147
- return { content: [{ type: "text", text: `Ran '${action}'.` }] };
148
- } catch (e) {
149
- return errText(e);
150
- }
215
+ await call("command.run", { action });
216
+ return text(`Ran ${action}.`);
217
+ },
218
+ );
219
+
220
+ tool(
221
+ "apply_filter",
222
+ {
223
+ title: "Apply a filter",
224
+ description:
225
+ "Apply a filter to the active layer, as one undo step named after it: invert, desaturate, posterize, brightness_contrast, hsl_adjust, levels, curves, gradient_map, palettize, blur, pixelate, outline, drop_shadow, despeckle, convolve, convolve_5x5, color_reduce, palette_variants (the user's variant on show; it has no settings). Settings the call leaves out keep the user's remembered (or default) values. A selection limits it, as in the editor. With output adjustment, every filter but color_reduce becomes an adjustment layer instead: the art stays as it is and the change can be edited or applied later (see adjustment_layer).",
226
+ input: {
227
+ name: z.string().describe("The filter, e.g. blur or hsl_adjust."),
228
+ params: z
229
+ .union([z.record(z.any()), z.array(z.number().int())])
230
+ .optional()
231
+ .describe(
232
+ 'Settings by the labels PixelAOI shows ({"Radius": 3}, {"Hue": 30, "Saturation": -20}) or in order. Curves (an object only): {"all": [[input, output], ...], "red"/"green"/"blue": ...}, 0-255, at most 16 points a channel; channels left out keep the remembered curve. Gradient Map (an object only): {"stops": [[position, r, g, b], ...]}, 0-255, 1 to 16 stops. Palette Cycling (an object only, adjustment output only): {"runs": [{"colours": [[r, g, b], ...], "every": 1, "reverse": false}]}, at most 8 runs of 64 colours, every 1-16 frames a step. Convolve: "Divide by" 0 = Auto (the sum of the weights), 1 = the Divisor; giving a Divisor selects it. Outline and Drop Shadow take their colour as color: [r, g, b].',
233
+ ),
234
+ output: z
235
+ .enum(["pixels", "new_layer", "adjustment"])
236
+ .optional()
237
+ .describe("Change the layer (default), put the result on a new layer above it, or add an adjustment layer doing it live (every filter but color_reduce; palette_cycling only this way)."),
238
+ affects: z
239
+ .enum(["layer", "below"])
240
+ .optional()
241
+ .describe("output adjustment: only the layer under it (default), or everything under it in its folder."),
242
+ frames: z.enum(["this", "selected", "all"]).optional().describe("Which frames (default: the frame on show)."),
243
+ },
244
+ annotations: CHANGES,
245
+ },
246
+ async (a) => {
247
+ const args = { name: a.name };
248
+ if (a.params != null) args.params = a.params;
249
+ if (a.output) args.output = a.output;
250
+ if (a.affects) args.affects = a.affects;
251
+ if (a.frames) args.frames = a.frames;
252
+ const r = await call("filter.apply", args, 60000);
253
+ if (a.output === "adjustment") return text(`${r.undo_label}: adjustment layer ${r.layer} (uid ${r.uid}); the art is unchanged.`);
254
+ return text(r.undo_label ? `Applied ${r.ran} on ${r.frames_written} frame(s) (undo: ${r.undo_label}).` : `${r.ran} changed nothing (no undo step).`);
151
255
  },
152
256
  );
153
257
 
154
- server.tool(
258
+ // A layer by its index (as get_state's stack lists it), {layer: index} or
259
+ // {uid: "…"} (the uid as text, as the stack gives it: it is too big for a JS
260
+ // number); a folder by {folder: id}.
261
+ const stackItem = z.union([
262
+ z.number().int().nonnegative(),
263
+ z.object({ layer: z.number().int().nonnegative() }),
264
+ z.object({ uid: z.union([z.string(), z.number().int().nonnegative()]) }),
265
+ z.object({ folder: z.number().int().nonnegative() }),
266
+ ]);
267
+
268
+ tool(
269
+ "layers",
270
+ {
271
+ title: "Arrange layers",
272
+ description:
273
+ "Arrange the layer stack: select, move, group, ungroup, new_folder, set, delete, duplicate or merge. Each change is one undo step and every frame follows. Name a layer by its index (as get_state's stack lists it), {layer: index} or {uid: '…'} (the uid text the stack gives, which survives moves); a folder by {folder: id}. Leaving out items acts on the current selection. Folders hold layers and folders (drawn together, then laid down at the folder's opacity) and may be empty. Locked layers and folders stay put, and nothing moves past a locked layer. Returns the stack (top to bottom) and the selection.",
274
+ input: {
275
+ action: z
276
+ .enum(["select", "move", "group", "ungroup", "new_folder", "set", "delete", "duplicate", "merge"])
277
+ .describe("select: choose items; move: put items at a place; group: items into a new folder where the topmost is; ungroup: a folder away, what it held staying; new_folder: an empty folder (default: above the focus); set: change items; delete; duplicate (each copy right above its original); merge: selected layers into the lowest."),
278
+ items: z.array(stackItem).optional().describe("What to act on (default: the selection)."),
279
+ focus: stackItem.optional().describe("select: the one painted and filtered (default: the last named)."),
280
+ above: stackItem.optional().describe("move / new_folder: right above this item, in its folder."),
281
+ below: stackItem.optional().describe("move / new_folder: right below this item, in its folder."),
282
+ into: z.number().int().nonnegative().nullable().optional().describe("move / new_folder: into this folder (its id), or null for the top level."),
283
+ position: z.enum(["top", "bottom"]).optional().describe("With into: the folder's top (default) or bottom."),
284
+ folder: z.number().int().nonnegative().optional().describe("ungroup: the folder's id."),
285
+ name: z.string().optional().describe("group / new_folder: the new folder's name; set: one item's name."),
286
+ visible: z.boolean().optional().describe("set: shown or hidden (a lighting map: used by lighting or not)."),
287
+ locked: z.boolean().optional().describe("set: locked or not."),
288
+ opacity: z.number().int().min(0).max(255).optional().describe("set: 0-255 (a folder's own opacity for a folder)."),
289
+ blend: z.string().optional().describe("set: a layer's blend mode by name (Normal, Multiply, Screen, Overlay, Soft Light, …)."),
290
+ draft: z.boolean().optional().describe("set: a layer left out of exports."),
291
+ clipping: z.boolean().optional().describe("set: a layer clipped to the one under it."),
292
+ expanded: z.boolean().optional().describe("set: a folder open or folded in the panel."),
293
+ },
294
+ annotations: CHANGES,
295
+ },
296
+ async (a) => {
297
+ const { action, ...rest } = a;
298
+ const args = {};
299
+ for (const [k, v] of Object.entries(rest)) if (v !== undefined) args[k] = v;
300
+ const r = await call(`layer.${action}`, args);
301
+ return json(r.undo_label ? `${r.undo_label} (one undo step).` : "Selected.", { stack: r.stack, selection: r.selection });
302
+ },
303
+ );
304
+
305
+ tool(
306
+ "adjustment_layer",
307
+ {
308
+ title: "Adjustment layers",
309
+ description:
310
+ "Adjustment layers: a layer with no art of its own that changes what is under it, live, and can be changed again or applied any time (the art under it stays as it is). add: a new one right above the layer in focus (its mask: the selection, else everything; one mask for every frame). set: change one's settings, its eye (enabled) or what it affects. remove: delete it. bake: apply it to the pixels of what it changes, in every frame, and remove it (refused while another adjustment under it changes the same layers: bake that one first). Kinds: invert, desaturate, posterize, brightness_contrast, hsl_adjust, levels, curves, gradient_map, palette_variants (a copy of the user's variant on show), palettize (snap to a copy of the active palette), palette_cycling (runs of colours stepping once a frame; give its runs as params), outline and drop_shadow (their colour as params color: [r, g, b]), blur, pixelate, despeckle, convolve, convolve_5x5. A new one takes the focus, and filters refuse an adjustment layer in focus: select an art layer before apply_filter. Its strength is the layer's opacity (layers set). Each call is one undo step. Returns the stack (top to bottom) and the selection.",
311
+ input: {
312
+ action: z.enum(["add", "set", "remove", "bake"]),
313
+ kind: z.string().optional().describe("add: what it does (levels, hsl_adjust, palettize, ...)."),
314
+ params: z
315
+ .union([z.record(z.any()), z.array(z.number().int())])
316
+ .optional()
317
+ .describe('add / set: settings as apply_filter takes them ({"Hue": 30}, Curves points, Gradient Map stops, Palette Cycling runs); set merges them into its own.'),
318
+ affects: z
319
+ .enum(["layer", "below"])
320
+ .optional()
321
+ .describe("add (default layer) / set: only the layer (or folder) right under it, or everything under it in its folder."),
322
+ enabled: z.boolean().optional().describe("set: its eye, on or off."),
323
+ item: stackItem.optional().describe("set / remove / bake: which adjustment layer (default: the one in focus)."),
324
+ },
325
+ annotations: CHANGES,
326
+ },
327
+ async (a) => {
328
+ const args = {};
329
+ for (const [k, v] of Object.entries(a)) if (v !== undefined) args[k] = v;
330
+ const r = await call("layer.adjustment", args, 60000);
331
+ return json(r.undo_label ? `${r.undo_label} (one undo step).` : "Nothing changed.", { stack: r.stack, selection: r.selection });
332
+ },
333
+ );
334
+
335
+ // ── Painting ───────────────────────────────────────────────────────────────
336
+
337
+ tool(
155
338
  "set_canvas_image",
156
- "Paint an image (e.g. a pixelated PNG) into the PixelAOI editor. If the image matches the current canvas size it paints onto the open document; if not, it opens as a NEW document (override with as_new_document). Provide exactly one of image_path or png_base64.",
157
339
  {
158
- image_path: z.string().optional().describe("Local path to a PNG/JPG/WEBP to paint."),
159
- png_base64: z.string().optional().describe("Base64-encoded image (alternative to image_path)."),
160
- as_new_document: z
161
- .boolean()
162
- .optional()
163
- .describe("Force a new document (true) or paint onto the current canvas (false). Default: auto by size."),
164
- name: z.string().optional().describe("Title for the new document, when one is created."),
340
+ title: "Paint an image",
341
+ description:
342
+ "Paint an image (a pixelated PNG, say) into PixelAOI. When it matches the canvas size it paints onto the open document; when not, it opens as a NEW document (as_new_document overrides that). Give exactly one of image_path or png_base64.",
343
+ input: {
344
+ image_path: z.string().optional().describe("Local path to a PNG/JPG/WEBP to paint."),
345
+ png_base64: z.string().optional().describe("Base64-encoded image (instead of image_path)."),
346
+ as_new_document: z.boolean().optional().describe("A new document (true) or onto the open canvas (false). Default: by size."),
347
+ name: z.string().optional().describe("The new document's name, when one is made."),
348
+ },
349
+ annotations: CHANGES,
165
350
  },
166
351
  async (a) => {
167
- try {
168
- let b64 = a.png_base64 ? stripDataUrl(a.png_base64) : undefined;
169
- if (!b64 && a.image_path) b64 = (await readFile(a.image_path)).toString("base64");
170
- if (!b64) return errText(new Error("provide image_path or png_base64"));
171
- const args = { png_base64: b64 };
172
- if (a.as_new_document != null) args.as_new_document = a.as_new_document;
173
- if (a.name) args.name = a.name;
174
- const r = await call("canvas.set_image", args, 60000);
175
- return {
176
- content: [{ type: "text", text: `Painted ${r.width}x${r.height} (${r.document} document).` }],
177
- };
178
- } catch (e) {
179
- return errText(e);
180
- }
352
+ let b64 = a.png_base64 ? stripDataUrl(a.png_base64) : undefined;
353
+ if (!b64 && a.image_path) b64 = (await readFile(a.image_path)).toString("base64");
354
+ if (!b64) throw new Error("give image_path or png_base64");
355
+ const args = { png_base64: b64 };
356
+ if (a.as_new_document != null) args.as_new_document = a.as_new_document;
357
+ if (a.name) args.name = a.name;
358
+ const r = await call("canvas.set_image", args, 60000);
359
+ return text(`Painted ${r.width}×${r.height} (${r.document} document).`);
181
360
  },
182
361
  );
183
362
 
184
- server.tool(
363
+ tool(
185
364
  "set_pixels",
186
- "Paint a raw RGBA block onto the current canvas at (x,y), clipped to the canvas bounds. rgba_base64 is base64 of exactly w*h*4 RGBA bytes (row-major, top-left origin).",
187
365
  {
188
- x: z.number().int().describe("Left edge on the canvas."),
189
- y: z.number().int().describe("Top edge on the canvas."),
190
- w: z.number().int().positive().describe("Block width in pixels."),
191
- h: z.number().int().positive().describe("Block height in pixels."),
192
- rgba_base64: z.string().describe("Base64 of w*h*4 RGBA bytes."),
366
+ title: "Paint pixels",
367
+ description:
368
+ "Paint a raw RGBA block onto the canvas at (x, y), clipped to the canvas. rgba_base64 is base64 of exactly w*h*4 RGBA bytes (row by row, from the top left).",
369
+ input: {
370
+ x: z.number().int().describe("Left edge on the canvas."),
371
+ y: z.number().int().describe("Top edge on the canvas."),
372
+ w: z.number().int().positive().describe("Block width in pixels."),
373
+ h: z.number().int().positive().describe("Block height in pixels."),
374
+ rgba_base64: z.string().describe("Base64 of w*h*4 RGBA bytes."),
375
+ },
376
+ annotations: { ...CHANGES, idempotentHint: true },
193
377
  },
194
378
  async (a) => {
195
- try {
196
- await call("canvas.set_pixels", {
197
- x: a.x,
198
- y: a.y,
199
- w: a.w,
200
- h: a.h,
201
- rgba_base64: stripDataUrl(a.rgba_base64),
202
- });
203
- return { content: [{ type: "text", text: `Painted ${a.w}x${a.h} at (${a.x},${a.y}).` }] };
204
- } catch (e) {
205
- return errText(e);
206
- }
379
+ await call("canvas.set_pixels", { x: a.x, y: a.y, w: a.w, h: a.h, rgba_base64: stripDataUrl(a.rgba_base64) });
380
+ return text(`Painted ${a.w}×${a.h} at (${a.x}, ${a.y}).`);
207
381
  },
208
382
  );
209
383
 
210
- server.tool(
384
+ tool(
211
385
  "pixelate_image",
212
- "Turn an image into grid-perfect pixel art LOCALLY using the editor's built-in engine (auto-detects the native grid + palette), then paint it into PixelAOI. No cloud, no API key. Provide exactly one of image_path, png_base64, or image_url.",
213
386
  {
214
- image_path: z.string().optional().describe("Local path to the source image (png/jpg/webp)."),
215
- png_base64: z.string().optional().describe("Base64-encoded image (alternative to image_path)."),
216
- image_url: z.string().optional().describe("URL to fetch the image from."),
217
- auto: z.boolean().optional().describe("Auto-detect native grid + palette (default true)."),
218
- colors: z.number().int().positive().optional().describe("Force a palette size; omit for auto."),
219
- target_width: z.number().int().positive().optional().describe("Force output width (when auto is off)."),
220
- target_height: z.number().int().positive().optional().describe("Force output height (when auto is off)."),
221
- seed: z.number().int().optional().describe("Determinism seed (default 42)."),
222
- output_path: z.string().optional().describe("If set, also save the pixelated PNG here."),
223
- paint: z.boolean().optional().describe("Paint the result into the editor (default true)."),
224
- as_new_document: z.boolean().optional().describe("Force new doc (true) or paint onto current (false)."),
225
- name: z.string().optional().describe("Name for the new document, if created."),
387
+ title: "Pixelate an image",
388
+ description:
389
+ "Turn an image into grid-perfect pixel art with PixelAOI's own engine, on this computer (it finds the native grid and palette), then paint it into PixelAOI. No cloud and no key. Give exactly one of image_path, png_base64 or image_url.",
390
+ input: {
391
+ image_path: z.string().optional().describe("Local path to the source image (png/jpg/webp)."),
392
+ png_base64: z.string().optional().describe("Base64-encoded image (instead of image_path)."),
393
+ image_url: z.string().optional().describe("A URL to fetch the image from."),
394
+ auto: z.boolean().optional().describe("Find the native grid and palette (default true)."),
395
+ colors: z.number().int().positive().optional().describe("A palette size; leave out for auto."),
396
+ target_width: z.number().int().positive().optional().describe("The output width (when auto is off)."),
397
+ target_height: z.number().int().positive().optional().describe("The output height (when auto is off)."),
398
+ seed: z.number().int().optional().describe("Determinism seed (default 42)."),
399
+ output_path: z.string().optional().describe("Also save the pixelated PNG here."),
400
+ paint: z.boolean().optional().describe("Paint the result into PixelAOI (default true)."),
401
+ as_new_document: z.boolean().optional().describe("A new document (true) or onto the open canvas (false)."),
402
+ name: z.string().optional().describe("The new document's name, when one is made."),
403
+ },
404
+ annotations: { ...CHANGES, openWorldHint: true },
226
405
  },
227
406
  async (a) => {
228
- try {
229
- let b64 = a.png_base64 ? stripDataUrl(a.png_base64) : undefined;
230
- if (!b64 && a.image_path) b64 = (await readFile(a.image_path)).toString("base64");
231
- if (!b64 && a.image_url) b64 = Buffer.from(await (await fetch(a.image_url)).arrayBuffer()).toString("base64");
232
- if (!b64) return errText(new Error("provide image_path, png_base64, or image_url"));
233
-
234
- const pargs = { png_base64: b64 };
235
- if (a.auto != null) pargs.auto = a.auto;
236
- if (a.colors != null) pargs.colors = a.colors;
237
- if (a.target_width != null) pargs.target_width = a.target_width;
238
- if (a.target_height != null) pargs.target_height = a.target_height;
239
- if (a.seed != null) pargs.seed = a.seed;
240
- const r = await call("canvas.pixelate", pargs, 60000);
241
-
242
- let summary = `Pixelated to ${r.width}x${r.height}.`;
243
- if (a.output_path) {
244
- await writeFile(a.output_path, Buffer.from(r.png_base64, "base64"));
245
- summary += ` Saved to ${a.output_path}.`;
246
- }
247
- if (a.paint !== false) {
248
- const sargs = { png_base64: r.png_base64 };
249
- if (a.as_new_document != null) sargs.as_new_document = a.as_new_document;
250
- if (a.name) sargs.name = a.name;
251
- const p = await call("canvas.set_image", sargs, 60000);
252
- summary += ` Painted into PixelAOI (${p.document} document).`;
253
- }
254
- return {
255
- content: [
256
- { type: "text", text: summary },
257
- { type: "image", data: r.png_base64, mimeType: "image/png" },
258
- ],
259
- };
260
- } catch (e) {
261
- return errText(e);
407
+ let b64 = a.png_base64 ? stripDataUrl(a.png_base64) : undefined;
408
+ if (!b64 && a.image_path) b64 = (await readFile(a.image_path)).toString("base64");
409
+ if (!b64 && a.image_url) b64 = Buffer.from(await (await fetch(a.image_url)).arrayBuffer()).toString("base64");
410
+ if (!b64) throw new Error("give image_path, png_base64 or image_url");
411
+ const pargs = { png_base64: b64 };
412
+ for (const k of ["auto", "colors", "target_width", "target_height", "seed"]) if (a[k] != null) pargs[k] = a[k];
413
+ const r = await call("canvas.pixelate", pargs, 60000);
414
+ let summary = `Pixelated to ${r.width}×${r.height}.`;
415
+ if (a.output_path) {
416
+ await writeFile(a.output_path, Buffer.from(r.png_base64, "base64"));
417
+ summary += ` Saved to ${a.output_path}.`;
262
418
  }
419
+ if (a.paint !== false) {
420
+ const sargs = { png_base64: r.png_base64 };
421
+ if (a.as_new_document != null) sargs.as_new_document = a.as_new_document;
422
+ if (a.name) sargs.name = a.name;
423
+ const p = await call("canvas.set_image", sargs, 60000);
424
+ summary += ` Painted into PixelAOI (${p.document} document).`;
425
+ }
426
+ return {
427
+ content: [
428
+ { type: "text", text: summary },
429
+ { type: "image", data: r.png_base64, mimeType: "image/png" },
430
+ ],
431
+ };
263
432
  },
264
433
  );
265
434
 
266
- server.tool(
435
+ tool(
267
436
  "extrude_animate",
268
- "Animate the current canvas as a 3D extrusion turntable: extrudes the silhouette and rotates it across the timeline, baking one keyframe per frame. Builds the frames for you. Great for a heart that turns side to side.",
269
437
  {
270
- frames: z.number().int().min(2).max(64).optional().describe("Timeline frame count (default 16)."),
271
- from_deg: z.number().optional().describe("Start Y rotation, degrees (default -35)."),
272
- to_deg: z.number().optional().describe("End Y rotation, degrees (default 35)."),
273
- depth: z.number().optional().describe("Extrude depth %, default 70."),
274
- tint: z.boolean().optional().describe("Keep source colors (default true)."),
275
- rot_x: z.number().optional().describe("Fixed X tilt, degrees (default 12)."),
276
- ping_pong: z.boolean().optional().describe("Swing back and forth (default true) vs one-way sweep."),
438
+ title: "3D turntable",
439
+ description:
440
+ "Animate the canvas as a 3D extrusion turntable: the silhouette is extruded and turned across the timeline, one frame per step, built for you. A heart that turns side to side, a coin that spins.",
441
+ input: {
442
+ frames: z.number().int().min(2).max(64).optional().describe("How many frames (default 16)."),
443
+ from_deg: z.number().optional().describe("Start turn, degrees (default -35)."),
444
+ to_deg: z.number().optional().describe("End turn, degrees (default 35)."),
445
+ depth: z.number().optional().describe("Extrusion depth %, default 70."),
446
+ tint: z.boolean().optional().describe("Keep the source's colours (default true)."),
447
+ rot_x: z.number().optional().describe("A fixed tilt, degrees (default 12)."),
448
+ ping_pong: z.boolean().optional().describe("Swing back and forth (default true), or one sweep."),
449
+ },
450
+ annotations: CHANGES,
277
451
  },
278
452
  async (a) => {
279
- try {
280
- const args = {};
281
- for (const k of ["frames", "from_deg", "to_deg", "depth", "tint", "rot_x", "ping_pong"]) {
282
- if (a[k] != null) args[k] = a[k];
283
- }
284
- const r = await call("canvas.extrude_animate", args, 60000);
285
- return {
286
- content: [
287
- {
288
- type: "text",
289
- text: `Baked ${r.frames} animation frames (turn ${r.from_deg}° -> ${r.to_deg}°). Play the timeline to see it.`,
290
- },
291
- ],
292
- };
293
- } catch (e) {
294
- return errText(e);
295
- }
453
+ const args = {};
454
+ for (const k of ["frames", "from_deg", "to_deg", "depth", "tint", "rot_x", "ping_pong"]) if (a[k] != null) args[k] = a[k];
455
+ const r = await call("canvas.extrude_animate", args, 60000);
456
+ return text(`Baked ${r.frames} frames (turning ${r.from_deg}° to ${r.to_deg}°). Play the timeline to see it.`);
296
457
  },
297
458
  );
298
459
 
299
- server.tool(
460
+ // ── Blueprints ─────────────────────────────────────────────────────────────
461
+
462
+ const GRAPH = z.enum(["Particle", "Canvas", "Ai", "Layer"]);
463
+
464
+ tool(
300
465
  "blueprint_node_kinds",
301
- "The COMPLETE catalog of node kinds that can be added to a blueprint graph, grouped by category (use these exact names with blueprint_add_node). This is the full set of addable nodes — NOT the nodes already in the graph (use blueprint_list for that). Defaults to the active graph; pass `graph` to query a specific kind before selecting it.",
302
466
  {
303
- graph: z
304
- .enum(["Particle", "Canvas", "Ai", "Layer"])
305
- .optional()
306
- .describe("Graph kind to query; omit for the active graph."),
307
- },
308
- async ({ graph }) => {
309
- try {
310
- const r = await call("blueprint.node_kinds", graph ? { graph } : undefined);
311
- return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
312
- } catch (e) {
313
- return errText(e);
314
- }
467
+ title: "Blueprint node kinds",
468
+ description:
469
+ "Every kind of node a Blueprint graph can take, by category (use these names with blueprint_add_node). This is the catalog, not the nodes in the graph (see blueprint_list). The active graph's, or another kind's with graph.",
470
+ input: { graph: GRAPH.optional().describe("The graph kind to ask about; the active graph's when left out.") },
471
+ annotations: READS,
315
472
  },
473
+ async ({ graph }) => json("", await call("blueprint.node_kinds", graph ? { graph } : undefined)),
316
474
  );
317
475
 
318
- server.tool(
476
+ tool(
319
477
  "blueprint_list",
320
- "Inspect the active blueprint graph: its kind, nodes (id, kind, position), links, and per-param value-wire bindings.",
321
- {},
322
- async () => {
323
- try {
324
- const r = await call("blueprint.list");
325
- return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
326
- } catch (e) {
327
- return errText(e);
328
- }
478
+ {
479
+ title: "Read the Blueprint",
480
+ description: "The active Blueprint graph: its kind, its nodes (id, kind, position, data), its links and each parameter's value wire.",
481
+ annotations: READS,
329
482
  },
483
+ async () => json("", await call("blueprint.list")),
330
484
  );
331
485
 
332
- server.tool(
486
+ tool(
333
487
  "blueprint_select_graph",
334
- "Switch which blueprint graph is active (Particle, Canvas, Ai, or Layer) and return its summary. Call this before adding nodes so they land in the right graph.",
335
- { graph: z.enum(["Particle", "Canvas", "Ai", "Layer"]).describe("Graph kind to make active.") },
336
- async ({ graph }) => {
337
- try {
338
- const r = await call("blueprint.select_graph", { graph });
339
- return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
340
- } catch (e) {
341
- return errText(e);
342
- }
488
+ {
489
+ title: "Switch Blueprint graph",
490
+ description: "Make a Blueprint graph kind active (Particle, Canvas, Ai or Layer) and return its summary. Do this before adding nodes, so they land in the right graph.",
491
+ input: { graph: GRAPH.describe("The graph kind to make active.") },
492
+ annotations: { ...ADDS, idempotentHint: true },
343
493
  },
494
+ async ({ graph }) => json("", await call("blueprint.select_graph", { graph })),
344
495
  );
345
496
 
346
- server.tool(
497
+ tool(
347
498
  "blueprint_add_node",
348
- "Add a node to the active blueprint graph; returns the new node id. `kind` is a NodeKind name, e.g. (Canvas graph) CanvasNoise, CanvasGradient, CanvasShape, CanvasFilter, CanvasColorAdjust, PaletteMap, CanvasOutput; (Particle graph) Emitter, Forces, Lifetime. Use blueprint_list to see what's wireable.",
349
499
  {
350
- kind: z.string().describe("NodeKind name, e.g. CanvasNoise or CanvasOutput."),
351
- x: z.number().int().optional().describe("Graph X position (default 60)."),
352
- y: z.number().int().optional().describe("Graph Y position (default 60)."),
500
+ title: "Add a node",
501
+ description:
502
+ "Add a node to the active Blueprint graph; returns its id. kind is a node kind's name, e.g. (Canvas graph) CanvasNoise, CanvasGradient, CanvasShape, CanvasFilter, CanvasColorAdjust, PaletteMap, CanvasOutput; (Particle graph) Emitter, Forces, Lifetime. See blueprint_node_kinds for them all.",
503
+ input: {
504
+ kind: z.string().describe("The node kind, e.g. CanvasNoise or CanvasOutput."),
505
+ x: z.number().int().optional().describe("Graph X position (default 60)."),
506
+ y: z.number().int().optional().describe("Graph Y position (default 60)."),
507
+ },
508
+ annotations: ADDS,
353
509
  },
354
510
  async ({ kind, x, y }) => {
355
- try {
356
- const args = { kind };
357
- if (x != null) args.x = x;
358
- if (y != null) args.y = y;
359
- const r = await call("blueprint.add_node", args);
360
- return { content: [{ type: "text", text: `Added ${kind} as node ${r.id}.` }] };
361
- } catch (e) {
362
- return errText(e);
363
- }
511
+ const args = { kind };
512
+ if (x != null) args.x = x;
513
+ if (y != null) args.y = y;
514
+ const r = await call("blueprint.add_node", args);
515
+ return text(`Added ${kind} as node ${r.id}.`);
364
516
  },
365
517
  );
366
518
 
367
- server.tool(
519
+ tool(
368
520
  "blueprint_connect",
369
- "Connect node `from`'s output into node `to`'s input in the active graph (validated by the graph's wiring rules).",
370
521
  {
371
- from: z.number().int().describe("Source node id."),
372
- to: z.number().int().describe("Destination node id."),
522
+ title: "Connect nodes",
523
+ description: "Wire node from's output into node to's input in the active graph (checked against the graph's rules).",
524
+ input: {
525
+ from: z.number().int().describe("The source node's id."),
526
+ to: z.number().int().describe("The destination node's id."),
527
+ },
528
+ annotations: ADDS,
373
529
  },
374
530
  async ({ from, to }) => {
375
- try {
376
- await call("blueprint.connect", { from, to });
377
- return { content: [{ type: "text", text: `Connected ${from} -> ${to}.` }] };
378
- } catch (e) {
379
- return errText(e);
380
- }
531
+ await call("blueprint.connect", { from, to });
532
+ return text(`Connected ${from} → ${to}.`);
381
533
  },
382
534
  );
383
535
 
384
- server.tool(
536
+ tool(
385
537
  "blueprint_connect_value",
386
- "Wire a VALUE node (ValueConstant/ValueSine/ValueMath/…) to drive a parameter of another node — the 'value wire' / param-pin connection. `param` is a CanvasAnimParam name (e.g. NoiseScale); omit it to drive the target's primary param.",
387
538
  {
388
- from: z.number().int().describe("Value node id (the driver)."),
389
- to: z.number().int().describe("Target node id whose param is driven."),
390
- param: z.string().optional().describe("CanvasAnimParam to drive; omit for the target's primary param."),
539
+ title: "Drive a parameter",
540
+ description:
541
+ "Wire a value node (ValueConstant, ValueSine, ValueMath, …) to drive a parameter of another node: a value wire. param is a parameter's name (e.g. NoiseScale); leave it out to drive the target's main one.",
542
+ input: {
543
+ from: z.number().int().describe("The value node's id (the driver)."),
544
+ to: z.number().int().describe("The node whose parameter it drives."),
545
+ param: z.string().optional().describe("The parameter to drive; the target's main one when left out."),
546
+ },
547
+ annotations: ADDS,
391
548
  },
392
549
  async ({ from, to, param }) => {
393
- try {
394
- const args = { from, to };
395
- if (param) args.param = param;
396
- await call("blueprint.connect_value", args);
397
- return { content: [{ type: "text", text: `Value-wired ${from} -> ${to}${param ? " (" + param + ")" : ""}.` }] };
398
- } catch (e) {
399
- return errText(e);
400
- }
550
+ const args = { from, to };
551
+ if (param) args.param = param;
552
+ await call("blueprint.connect_value", args);
553
+ return text(`Value-wired ${from} → ${to}${param ? ` (${param})` : ""}.`);
401
554
  },
402
555
  );
403
556
 
404
- server.tool(
557
+ tool(
405
558
  "blueprint_set_data",
406
- "Set a node's discrete config (blend mode, shape kind, source mode, sizes, etc.). First call blueprint_list, copy that node's `data` object, change the field(s) you want, and pass the whole object back here. The data shape must match the node's current one.",
407
559
  {
408
- id: z.number().int().describe("Node id."),
409
- data: z.record(z.any()).describe("The node's full `data` object (from blueprint_list) with your edits."),
560
+ title: "Set node data",
561
+ description:
562
+ "Set a node's settings that aren't numbers (blend mode, shape kind, source mode, sizes, …). Call blueprint_list, copy that node's data object, change the fields you want, and pass the whole object back. Its shape must match the node's.",
563
+ input: {
564
+ id: z.number().int().describe("The node's id."),
565
+ data: z.record(z.any()).describe("The node's whole data object (from blueprint_list), with your changes."),
566
+ },
567
+ annotations: { ...CHANGES, idempotentHint: true },
410
568
  },
411
569
  async ({ id, data }) => {
412
- try {
413
- await call("blueprint.set_data", { id, data });
414
- return { content: [{ type: "text", text: `Updated data on node ${id}.` }] };
415
- } catch (e) {
416
- return errText(e);
417
- }
570
+ await call("blueprint.set_data", { id, data });
571
+ return text(`Updated node ${id}'s data.`);
418
572
  },
419
573
  );
420
574
 
421
- server.tool(
575
+ tool(
422
576
  "blueprint_set_param",
423
- "Set a continuous node parameter. `param` is a CanvasAnimParam name, e.g. NoiseScale, FilterStrength, GradientAngle, ShapeSize, ThresholdLevel, Hue, OffsetX, OffsetY, Rate, Speed, Lifetime. `value` is a number (clamped to the param's valid range).",
424
577
  {
425
- id: z.number().int().describe("Node id."),
426
- param: z.string().describe("CanvasAnimParam name, e.g. NoiseScale."),
427
- value: z.number().describe("New value (clamped to range)."),
578
+ title: "Set a parameter",
579
+ description:
580
+ "Set a node's number parameter: NoiseScale, FilterStrength, GradientAngle, ShapeSize, ThresholdLevel, Hue, OffsetX, OffsetY, Rate, Speed, Lifetime, … The value is kept within the parameter's range.",
581
+ input: {
582
+ id: z.number().int().describe("The node's id."),
583
+ param: z.string().describe("The parameter, e.g. NoiseScale."),
584
+ value: z.number().describe("Its new value (kept within its range)."),
585
+ },
586
+ annotations: { ...CHANGES, idempotentHint: true },
428
587
  },
429
588
  async ({ id, param, value }) => {
430
- try {
431
- await call("blueprint.set_param", { id, param, value });
432
- return { content: [{ type: "text", text: `Set ${param} = ${value} on node ${id}.` }] };
433
- } catch (e) {
434
- return errText(e);
435
- }
589
+ await call("blueprint.set_param", { id, param, value });
590
+ return text(`Set ${param} = ${value} on node ${id}.`);
436
591
  },
437
592
  );
438
593
 
439
- server.tool(
594
+ tool(
440
595
  "blueprint_delete_node",
441
- "Delete a node (and any links touching it) from the active graph.",
442
- { id: z.number().int().describe("Node id to delete.") },
596
+ {
597
+ title: "Delete a node",
598
+ description: "Delete a node, and the links touching it, from the active graph.",
599
+ input: { id: z.number().int().describe("The node to delete.") },
600
+ annotations: CHANGES,
601
+ },
443
602
  async ({ id }) => {
444
- try {
445
- await call("blueprint.delete_node", { id });
446
- return { content: [{ type: "text", text: `Deleted node ${id}.` }] };
447
- } catch (e) {
448
- return errText(e);
449
- }
603
+ await call("blueprint.delete_node", { id });
604
+ return text(`Deleted node ${id}.`);
450
605
  },
451
606
  );
452
607
 
453
- server.tool(
608
+ tool(
454
609
  "blueprint_bake",
455
- "Run / bake the active blueprint graph to the canvas (or to a layer/timeline, depending on the graph's output node).",
456
- {},
610
+ {
611
+ title: "Bake the Blueprint",
612
+ description: "Run the active Blueprint graph and bake what it makes into the document: the canvas, a layer or the timeline, as its output node says.",
613
+ annotations: CHANGES,
614
+ },
457
615
  async () => {
458
- try {
459
- const r = await call("blueprint.bake", undefined, 60000);
460
- return { content: [{ type: "text", text: `Baked the ${r.baked} graph.` }] };
461
- } catch (e) {
462
- return errText(e);
463
- }
616
+ const r = await call("blueprint.bake", undefined, 60000);
617
+ return text(`Baked the ${r.baked} graph.`);
618
+ },
619
+ );
620
+
621
+ // ── Animation and files ────────────────────────────────────────────────────
622
+
623
+ tool(
624
+ "add_frame",
625
+ {
626
+ title: "Add a frame",
627
+ description:
628
+ "Add a blank frame right after the one on show and show it, as the timeline's + does (one undo step). Paint it with set_canvas_image or set_pixels, which write the active layer of the frame on show.",
629
+ annotations: ADDS,
630
+ },
631
+ async () => {
632
+ const r = await call("frame.add");
633
+ return text(`Frame ${r.frame} of ${r.frames} added and on show.`);
634
+ },
635
+ );
636
+
637
+ tool(
638
+ "select_frame",
639
+ {
640
+ title: "Show a frame",
641
+ description: "Show a frame (from 0), as clicking it in the timeline does; the canvas and the painting tools then work on it.",
642
+ input: { index: z.number().int().min(0).describe("The frame, from 0.") },
643
+ annotations: { ...ADDS, idempotentHint: true },
644
+ },
645
+ async ({ index }) => {
646
+ await call("frame.select", { index });
647
+ return text(`Frame ${index} on show.`);
648
+ },
649
+ );
650
+
651
+ tool(
652
+ "set_frame_duration",
653
+ {
654
+ title: "Frame timing",
655
+ description: "How long a frame shows when the animation plays, 10 to 10000 milliseconds; the frame on show when index is left out.",
656
+ input: {
657
+ index: z.number().int().min(0).optional().describe("The frame, from 0; the one on show when left out."),
658
+ duration_ms: z.number().int().min(10).max(10000).describe("Milliseconds."),
659
+ },
660
+ annotations: { ...CHANGES, idempotentHint: true },
661
+ },
662
+ async ({ index, duration_ms }) => {
663
+ const r = await call("frame.set", { index, duration_ms });
664
+ return text(`Frame ${r.frame} shows for ${r.duration_ms} ms.`);
665
+ },
666
+ );
667
+
668
+ tool(
669
+ "add_tag",
670
+ {
671
+ title: "Tag frames",
672
+ description: "Tag a run of frames, from and to included (an animation such as idle or walk), as the timeline does.",
673
+ input: {
674
+ name: z.string().min(1).describe("The tag's name, e.g. idle."),
675
+ from: z.number().int().min(0).describe("The first frame, from 0."),
676
+ to: z.number().int().min(0).optional().describe("The last frame (from, when left out)."),
677
+ },
678
+ annotations: ADDS,
679
+ },
680
+ async ({ name, from, to }) => {
681
+ const r = await call("tag.add", { name, from, to });
682
+ return text(`Tag ${r.tag} '${r.name}' covers frames ${r.from}-${r.to}.`);
683
+ },
684
+ );
685
+
686
+ tool(
687
+ "save_document",
688
+ {
689
+ title: "Save the project",
690
+ description: "Save the open document as a PixelAOI project (.pxl) at an absolute path, without a dialog. It becomes the document's file, as Save As does.",
691
+ input: { path: z.string().describe("An absolute path ending in .pxl.") },
692
+ annotations: { ...CHANGES, idempotentHint: true },
693
+ },
694
+ async ({ path }) => {
695
+ const r = await call("document.save", { path });
696
+ return text(`Saved ${r.saved}.`);
697
+ },
698
+ );
699
+
700
+ tool(
701
+ "close_document",
702
+ {
703
+ title: "Close the document",
704
+ description:
705
+ "Close the open document's tab once it is saved (refused while it has unsaved changes: save_document first). The next tab comes up, or Home after the last.",
706
+ annotations: CHANGES,
707
+ },
708
+ async () => {
709
+ const r = await call("document.close");
710
+ return text(r.home ? "Closed; Home is up." : `Closed; ${r.documents} documents open.`);
464
711
  },
465
712
  );
466
713
 
package/package.json CHANGED
@@ -1,33 +1,33 @@
1
- {
2
- "name": "pixelate-mcp",
3
- "version": "0.3.1",
4
- "description": "MCP server to drive the PixelAOI editor: see the canvas, paint pixel art, pixelate images locally, run editor commands, and build node-based blueprints.",
5
- "type": "module",
6
- "bin": {
7
- "pixelate-mcp": "index.js"
8
- },
9
- "files": [
10
- "index.js",
11
- "README.md",
12
- "LICENSE"
13
- ],
14
- "homepage": "https://pixelaoi.pro",
15
- "keywords": [
16
- "mcp",
17
- "model-context-protocol",
18
- "pixel-art",
19
- "pixelaoi",
20
- "pixelate",
21
- "codex",
22
- "claude"
23
- ],
24
- "license": "MIT",
25
- "author": "PixelAOI",
26
- "engines": {
27
- "node": ">=18"
28
- },
29
- "dependencies": {
30
- "@modelcontextprotocol/sdk": "^1.12.0",
31
- "zod": "^3.23.8"
32
- }
33
- }
1
+ {
2
+ "name": "pixelate-mcp",
3
+ "version": "0.6.0",
4
+ "description": "PixelAOI's MCP server: lets an AI agent (Claude Code, Codex, Cursor, Claude Desktop) see the PixelAOI canvas, paint it, arrange layers, build animations, apply filters, pixelate images on your machine and wire Blueprint node graphs.",
5
+ "type": "module",
6
+ "bin": {
7
+ "pixelate-mcp": "index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "homepage": "https://pixelaoi.pro",
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "pixel-art",
19
+ "pixelaoi",
20
+ "pixelate",
21
+ "codex",
22
+ "claude"
23
+ ],
24
+ "license": "MIT",
25
+ "author": "PixelAOI",
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.32.1",
31
+ "zod": "^3.25.76"
32
+ }
33
+ }