pixelate-mcp 0.3.0 → 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 +139 -29
  3. package/index.js +714 -136
  4. package/package.json +33 -35
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,46 +1,156 @@
1
- # pixelate-mcp
1
+ # PixelAOI MCP
2
2
 
3
- An [MCP](https://modelcontextprotocol.io) server that turns any image into
4
- **grid-perfect, true pixel art** — it auto-detects the native pixel grid and a
5
- sensible palette among other things like reauthoring of the image. Works in **Codex, Claude Desktop, and Cursor**.
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.
6
7
 
7
- The heavy lifting runs in the cloud (Pixelate Cloud); this package is the small
8
- local connector that decodes your image and streams it to the service. You need
9
- a subscription key from **https://pixelaoi.pro**.
8
+ Everything runs on **your machine**: the server talks to the editor over a
9
+ localhost-only link (`127.0.0.1`). No cloud, no accounts, no API keys.
10
10
 
11
- ## Use it
11
+ ## 1. Turn on the agent link in PixelAOI
12
12
 
13
- Add to your MCP client config (Codex `~/.codex/config.toml`, Claude Desktop, or
14
- Cursor) — no install step needed, `npx` fetches it on demand:
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`.)
15
17
 
18
+ ## 2. Add the server to your agent
19
+
20
+ ### Claude Code
21
+ ```
22
+ claude mcp add pixelaoi -- npx -y pixelate-mcp
23
+ ```
24
+
25
+ ### Codex (`~/.codex/config.toml`)
16
26
  ```toml
17
- [mcp_servers.pixelate]
27
+ [mcp_servers.pixelaoi]
18
28
  command = "npx"
19
29
  args = ["-y", "pixelate-mcp"]
20
- env = { PIXELATE_API_URL = "https://pixelate.pixelaoi.cloud", PIXELATE_API_KEY = "YOUR_KEY" }
21
30
  ```
22
31
 
23
- Then ask your agent something like *"pixelate ./hero.png and save it next to the
24
- original."*
32
+ ### Claude Desktop / Cursor (JSON config)
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "pixelaoi": { "command": "npx", "args": ["-y", "pixelate-mcp"] }
37
+ }
38
+ }
39
+ ```
40
+
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.
45
+
46
+ ## Tools
47
+
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.
130
+
131
+ ## Example
132
+
133
+ > "Take ./hero.png, pixelate it, put it on the canvas, then bake a noise
134
+ > texture behind it with a Canvas blueprint."
135
+
136
+ The agent calls `pixelate_image`, then builds CanvasNoise → CanvasOutput with
137
+ the Blueprint tools and runs `blueprint_bake`.
25
138
 
26
- ## Tool: `pixelate_image`
139
+ ## Config
27
140
 
28
- | arg | description |
29
- |-----|-------------|
30
- | `image_path` / `image_base64` / `image_url` | the source image (provide one) |
31
- | `auto` | auto-detect grid + palette (default `true`) |
32
- | `target_width` / `target_height` | output size when `auto` is off or no grid is found |
33
- | `colors` | palette size (omit to auto-pick) |
34
- | `seed` | determinism seed |
35
- | `output_path` | if set, saves the PNG there |
141
+ - `PIXELAOI_BRIDGE_HOST` (default `127.0.0.1`)
142
+ - `PIXELAOI_BRIDGE_PORT` (default `48653`)
36
143
 
37
- Returns the pixel art as an image, plus a short summary.
144
+ ## If it can't connect
38
145
 
39
- ## Requirements
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.
40
149
 
41
- - Node.js ≥ 18
42
- - A `PIXELATE_API_KEY` (subscribe at https://pixelaoi.pro)
150
+ ## As a Claude Desktop extension
43
151
 
44
- ## License
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.
45
155
 
46
- MIT
156
+ Get the editor at [pixelaoi.pro](https://pixelaoi.pro).
package/index.js CHANGED
@@ -1,136 +1,714 @@
1
- #!/usr/bin/env node
2
-
3
- import { readFile, writeFile } from "node:fs/promises";
4
- import os from "node:os";
5
- import crypto from "node:crypto";
6
- import sharp from "sharp";
7
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
- import { z } from "zod";
10
-
11
- const API_URL = (process.env.PIXELATE_API_URL || "").replace(/\/+$/, "");
12
- const API_KEY = process.env.PIXELATE_API_KEY || "";
13
- if (!API_URL || !API_KEY) {
14
- console.error("pixelate-mcp: set PIXELATE_API_URL and PIXELATE_API_KEY environment variables.");
15
- process.exit(1);
16
- }
17
-
18
- // Stable per-machine id. A key binds to the first machine that uses it, so a
19
- // shared key won't work on someone else's device. Derived from host/user/home
20
- // (deterministic, no extra files); override with PIXELATE_MACHINE_ID if needed.
21
- let _uname = "";
22
- try { _uname = os.userInfo().username; } catch { /* ignore */ }
23
- const MACHINE_ID =
24
- process.env.PIXELATE_MACHINE_ID ||
25
- crypto.createHash("sha256").update([os.hostname(), _uname, os.homedir(), os.platform()].join("|")).digest("hex").slice(0, 24);
26
-
27
- const stripDataUrl = (s) => {
28
- const i = s.indexOf(",");
29
- return s.startsWith("data:") && i !== -1 ? s.slice(i + 1) : s;
30
- };
31
- const errText = (text) => ({ content: [{ type: "text", text }], isError: true });
32
- const usd = (cents) => `$${(cents / 100).toFixed(2)}`;
33
- // One-line billing summary from a /job done response (credits_cents may be null
34
- // for unlimited subscription keys).
35
- function billingLine(res) {
36
- const price = res.price_cents != null ? `${usd(res.price_cents)}/image` : null;
37
- if (res.credits_cents == null) return price ? ` Plan: unlimited (${price}).` : "";
38
- const imgs = res.price_cents ? Math.floor(res.credits_cents / res.price_cents) : null;
39
- return ` Charged ${price}; ${usd(res.credits_cents)} credit left${imgs != null ? ` (~${imgs} images)` : ""}.`;
40
- }
41
-
42
- async function jobReq(query, body) {
43
- const headers = { "x-api-key": API_KEY, "x-machine-id": MACHINE_ID };
44
- if (body) headers["content-type"] = "application/octet-stream";
45
- const res = await fetch(`${API_URL}/job?${query}`, {
46
- method: "POST",
47
- headers,
48
- body: body ?? undefined,
49
- });
50
- const j = await res.json().catch(() => ({}));
51
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${j.error || "request failed"}`);
52
- if (!j.success) throw new Error(j.error || "job error");
53
- return j;
54
- }
55
-
56
- const server = new McpServer({ name: "pixelate-mcp", version: "0.3.0" });
57
-
58
- server.tool(
59
- "pixelate_account",
60
- "Show your Pixelate Cloud credit balance (how much you have left) and the cost per image.",
61
- {},
62
- async () => {
63
- try {
64
- const res = await fetch(`${API_URL}/account`, { headers: { "x-api-key": API_KEY, "x-machine-id": MACHINE_ID } });
65
- const j = await res.json().catch(() => ({}));
66
- if (!res.ok || !j.success) return errText(`account error (${res.status}): ${j.error || "unknown"}`);
67
- const text =
68
- j.plan === "subscription"
69
- ? `Plan: unlimited subscription. Cost per image: ${usd(j.price_per_image_cents)} (not charged on your plan).`
70
- : `Balance: $${j.credits_usd} (~${j.images_remaining} images left). Cost per image: $${j.price_per_image_usd}.`;
71
- return { content: [{ type: "text", text }] };
72
- } catch (e) {
73
- return errText(`pixelate-mcp error: ${e?.message || e}`);
74
- }
75
- },
76
- );
77
-
78
- server.tool(
79
- "pixelate_image",
80
- "Convert an image into grid-perfect, true pixel art. Auto-detects the native pixel grid and a sensible palette. Provide exactly one of image_path, image_base64, or image_url.",
81
- {
82
- image_path: z.string().optional().describe("Local path to the source image (png/jpg/webp)."),
83
- image_base64: z.string().optional().describe("Base64-encoded image (alternative to image_path)."),
84
- image_url: z.string().optional().describe("URL to fetch the image from (alternative to image_path)."),
85
- auto: z.boolean().optional().describe("Auto-detect native grid + palette (default true)."),
86
- target_width: z.number().int().positive().optional().describe("Output width; used when auto is off or no grid is detected."),
87
- target_height: z.number().int().positive().optional().describe("Output height; used when auto is off or no grid is detected."),
88
- colors: z.number().int().positive().optional().describe("Palette size; omit to auto-pick."),
89
- seed: z.number().int().optional().describe("Determinism seed (default 42)."),
90
- output_path: z.string().optional().describe("If set, save the resulting PNG to this path."),
91
- },
92
- async (a) => {
93
- try {
94
- let buf;
95
- if (a.image_base64) buf = Buffer.from(stripDataUrl(a.image_base64), "base64");
96
- else if (a.image_path) buf = await readFile(a.image_path);
97
- else if (a.image_url) buf = Buffer.from(await (await fetch(a.image_url)).arrayBuffer());
98
- else return errText("Provide one of image_path, image_base64, or image_url.");
99
-
100
- // Decode locally to raw RGBA (no Worker decode -> no decode CPU on the edge).
101
- const { data, info } = await sharp(buf).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
102
-
103
- // Start: raw bytes go in the binary body; params in the query string.
104
- const q = new URLSearchParams({ action: "start", w: String(info.width), h: String(info.height), auto: (a.auto ?? true) ? "1" : "0" });
105
- if (a.colors) q.set("colors", String(a.colors));
106
- if (a.seed != null) q.set("seed", String(a.seed));
107
- if (a.target_width) q.set("tw", String(a.target_width));
108
- if (a.target_height) q.set("th", String(a.target_height));
109
-
110
- let res = await jobReq(q.toString(), data);
111
- const jobId = res.job_id;
112
- let guard = 0;
113
- while (!res.done) {
114
- if (++guard > 1000) return errText("pixelate-mcp: job did not converge");
115
- res = await jobReq(`action=step&id=${encodeURIComponent(jobId)}`);
116
- }
117
-
118
- let summary = `Pixel art ${res.width}x${res.height}.`;
119
- if (a.output_path) {
120
- await writeFile(a.output_path, Buffer.from(res.image_base64, "base64"));
121
- summary += ` Saved to ${a.output_path}.`;
122
- }
123
- summary += billingLine(res);
124
- return {
125
- content: [
126
- { type: "text", text: summary },
127
- { type: "image", data: res.image_base64, mimeType: "image/png" },
128
- ],
129
- };
130
- } catch (e) {
131
- return errText(`pixelate-mcp error: ${e?.message || e}`);
132
- }
133
- },
134
- );
135
-
136
- await server.connect(new StdioServerTransport());
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.
6
+ import net from "node:net";
7
+ import { readFile, writeFile } from "node:fs/promises";
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { z } from "zod";
11
+
12
+ const VERSION = "0.6.0";
13
+ const HOST = process.env.PIXELAOI_BRIDGE_HOST || "127.0.0.1";
14
+ const PORT = Number(process.env.PIXELAOI_BRIDGE_PORT || 48653);
15
+
16
+ // ── The bridge ─────────────────────────────────────────────────────────────
17
+ // One JSON request per line ({id, op, args}), answered in order
18
+ // ({id, ok, result | error}).
19
+
20
+ let sock = null;
21
+ let connected = false;
22
+ let buffer = "";
23
+ let nextId = 1;
24
+ const pending = new Map();
25
+
26
+ function teardown(err) {
27
+ if (sock) {
28
+ try { sock.destroy(); } catch {}
29
+ }
30
+ sock = null;
31
+ connected = false;
32
+ buffer = "";
33
+ for (const [, p] of pending) {
34
+ clearTimeout(p.timer);
35
+ p.reject(err || new Error("the link to PixelAOI closed"));
36
+ }
37
+ pending.clear();
38
+ }
39
+
40
+ function connect() {
41
+ if (connected && sock) return Promise.resolve();
42
+ return new Promise((resolve, reject) => {
43
+ const s = net.createConnection({ host: HOST, port: PORT });
44
+ s.setNoDelay(true);
45
+ let opened = false;
46
+ s.once("connect", () => {
47
+ opened = true;
48
+ sock = s;
49
+ connected = true;
50
+ resolve();
51
+ });
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
+ }
59
+ connected = false;
60
+ reject(
61
+ new Error(
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.)`,
65
+ ),
66
+ );
67
+ });
68
+ s.on("data", (chunk) => {
69
+ buffer += chunk.toString("utf8");
70
+ let nl;
71
+ while ((nl = buffer.indexOf("\n")) !== -1) {
72
+ const line = buffer.slice(0, nl).trim();
73
+ buffer = buffer.slice(nl + 1);
74
+ if (!line) continue;
75
+ let msg;
76
+ try {
77
+ msg = JSON.parse(line);
78
+ } catch {
79
+ continue;
80
+ }
81
+ const p = pending.get(msg.id);
82
+ if (!p) continue;
83
+ pending.delete(msg.id);
84
+ clearTimeout(p.timer);
85
+ if (msg.ok) p.resolve(msg.result);
86
+ else p.reject(new Error(msg.error || "PixelAOI refused it"));
87
+ }
88
+ });
89
+ s.on("close", () => teardown(new Error("the link to PixelAOI closed")));
90
+ });
91
+ }
92
+
93
+ async function call(op, args, timeoutMs = 30000) {
94
+ await connect();
95
+ const id = nextId++;
96
+ return new Promise((resolve, reject) => {
97
+ const timer = setTimeout(() => {
98
+ pending.delete(id);
99
+ reject(new Error(`PixelAOI didn't answer '${op}' within ${timeoutMs / 1000}s`));
100
+ }, timeoutMs);
101
+ pending.set(id, { resolve, reject, timer });
102
+ try {
103
+ sock.write(JSON.stringify({ id, op, args: args ?? null }) + "\n");
104
+ } catch (e) {
105
+ clearTimeout(timer);
106
+ pending.delete(id);
107
+ reject(e);
108
+ }
109
+ });
110
+ }
111
+
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));
114
+ const stripDataUrl = (s) => {
115
+ const i = s.indexOf(",");
116
+ return s.startsWith("data:") && i !== -1 ? s.slice(i + 1) : s;
117
+ };
118
+
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 ─────────────────────────────────────────────────────────────────
173
+
174
+ tool(
175
+ "get_state",
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,
181
+ },
182
+ async () => json("", await call("state.get")),
183
+ );
184
+
185
+ tool(
186
+ "get_canvas_image",
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
+ },
192
+ async () => {
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
+ };
200
+ },
201
+ );
202
+
203
+ // ── Commands, filters, layers ──────────────────────────────────────────────
204
+
205
+ tool(
206
+ "run_command",
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
+ },
214
+ async ({ action }) => {
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).`);
255
+ },
256
+ );
257
+
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(
338
+ "set_canvas_image",
339
+ {
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,
350
+ },
351
+ async (a) => {
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).`);
360
+ },
361
+ );
362
+
363
+ tool(
364
+ "set_pixels",
365
+ {
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 },
377
+ },
378
+ async (a) => {
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}).`);
381
+ },
382
+ );
383
+
384
+ tool(
385
+ "pixelate_image",
386
+ {
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 },
405
+ },
406
+ async (a) => {
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}.`;
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
+ };
432
+ },
433
+ );
434
+
435
+ tool(
436
+ "extrude_animate",
437
+ {
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,
451
+ },
452
+ async (a) => {
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.`);
457
+ },
458
+ );
459
+
460
+ // ── Blueprints ─────────────────────────────────────────────────────────────
461
+
462
+ const GRAPH = z.enum(["Particle", "Canvas", "Ai", "Layer"]);
463
+
464
+ tool(
465
+ "blueprint_node_kinds",
466
+ {
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,
472
+ },
473
+ async ({ graph }) => json("", await call("blueprint.node_kinds", graph ? { graph } : undefined)),
474
+ );
475
+
476
+ tool(
477
+ "blueprint_list",
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,
482
+ },
483
+ async () => json("", await call("blueprint.list")),
484
+ );
485
+
486
+ tool(
487
+ "blueprint_select_graph",
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 },
493
+ },
494
+ async ({ graph }) => json("", await call("blueprint.select_graph", { graph })),
495
+ );
496
+
497
+ tool(
498
+ "blueprint_add_node",
499
+ {
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,
509
+ },
510
+ async ({ kind, x, y }) => {
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}.`);
516
+ },
517
+ );
518
+
519
+ tool(
520
+ "blueprint_connect",
521
+ {
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,
529
+ },
530
+ async ({ from, to }) => {
531
+ await call("blueprint.connect", { from, to });
532
+ return text(`Connected ${from} → ${to}.`);
533
+ },
534
+ );
535
+
536
+ tool(
537
+ "blueprint_connect_value",
538
+ {
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,
548
+ },
549
+ async ({ from, to, param }) => {
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})` : ""}.`);
554
+ },
555
+ );
556
+
557
+ tool(
558
+ "blueprint_set_data",
559
+ {
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 },
568
+ },
569
+ async ({ id, data }) => {
570
+ await call("blueprint.set_data", { id, data });
571
+ return text(`Updated node ${id}'s data.`);
572
+ },
573
+ );
574
+
575
+ tool(
576
+ "blueprint_set_param",
577
+ {
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 },
587
+ },
588
+ async ({ id, param, value }) => {
589
+ await call("blueprint.set_param", { id, param, value });
590
+ return text(`Set ${param} = ${value} on node ${id}.`);
591
+ },
592
+ );
593
+
594
+ tool(
595
+ "blueprint_delete_node",
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
+ },
602
+ async ({ id }) => {
603
+ await call("blueprint.delete_node", { id });
604
+ return text(`Deleted node ${id}.`);
605
+ },
606
+ );
607
+
608
+ tool(
609
+ "blueprint_bake",
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
+ },
615
+ async () => {
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.`);
711
+ },
712
+ );
713
+
714
+ await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,35 +1,33 @@
1
- {
2
- "name": "pixelate-mcp",
3
- "version": "0.3.0",
4
- "description": "MCP server: turn any image into grid-perfect pixel art via the Pixelate Cloud API. Works in Codex, Claude Desktop, Cursor.",
5
- "type": "module",
6
- "bin": {
7
- "pixelate-mcp": "index.js"
8
- },
9
- "files": [
10
- "index.js",
11
- "README.md",
12
- "LICENSE"
13
- ],
14
- "keywords": [
15
- "mcp",
16
- "model-context-protocol",
17
- "pixel-art",
18
- "pixelate",
19
- "codex",
20
- "claude",
21
- "cursor",
22
- "aseprite"
23
- ],
24
- "homepage": "https://pixelaoi.pro",
25
- "license": "MIT",
26
- "author": "PixelAOI",
27
- "engines": {
28
- "node": ">=18"
29
- },
30
- "dependencies": {
31
- "@modelcontextprotocol/sdk": "^1.12.0",
32
- "sharp": "^0.33.5",
33
- "zod": "^3.23.8"
34
- }
35
- }
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
+ }