pixelate-mcp 0.2.0 → 0.3.1

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 (3) hide show
  1. package/README.md +76 -28
  2. package/index.js +467 -125
  3. package/package.json +5 -7
package/README.md CHANGED
@@ -1,46 +1,94 @@
1
1
  # pixelate-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 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.
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 bridge (`127.0.0.1`). No cloud, no accounts, no API keys.
10
10
 
11
- ## Use it
11
+ ## 1. Enable the bridge 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
+ 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
15
 
16
+ (Alternatively, launch the editor with the `PIXELAOI_AGENT_BRIDGE=1`
17
+ environment variable set.)
18
+
19
+ ## 2. Add the server to your agent
20
+
21
+ ### Claude Code
22
+ ```
23
+ claude mcp add pixelaoi -- npx -y pixelate-mcp
24
+ ```
25
+
26
+ ### Codex (`~/.codex/config.toml`)
16
27
  ```toml
17
- [mcp_servers.pixelate]
28
+ [mcp_servers.pixelaoi]
18
29
  command = "npx"
19
30
  args = ["-y", "pixelate-mcp"]
20
- env = { PIXELATE_API_URL = "https://pixelate.pixelaoi.cloud", PIXELATE_API_KEY = "YOUR_KEY" }
21
31
  ```
22
32
 
23
- Then ask your agent something like *"pixelate ./hero.png and save it next to the
24
- original."*
33
+ ### Claude Desktop / Cursor (JSON config)
34
+ ```json
35
+ {
36
+ "command": "npx",
37
+ "args": ["-y", "pixelate-mcp"]
38
+ }
39
+ ```
40
+
41
+ Restart the agent, make sure the editor is running with the bridge on, and
42
+ ask it to draw something.
43
+
44
+ ## Tools
45
+
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):
25
66
 
26
- ## Tool: `pixelate_image`
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.
27
79
 
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 |
80
+ ## Example
36
81
 
37
- Returns the pixel art as an image, plus a short summary.
82
+ > "Take ./hero.png, pixelate it, put it on the canvas, then bake a noise
83
+ > texture behind it with a Canvas blueprint."
38
84
 
39
- ## Requirements
85
+ The agent calls `pixelate_image` (local engine), then builds
86
+ CanvasNoise -> CanvasOutput with the blueprint tools and runs
87
+ `blueprint_bake`.
40
88
 
41
- - Node.js ≥ 18
42
- - A `PIXELATE_API_KEY` (subscribe at https://pixelaoi.pro)
89
+ ## Config
43
90
 
44
- ## License
91
+ - `PIXELAOI_BRIDGE_HOST` (default `127.0.0.1`)
92
+ - `PIXELAOI_BRIDGE_PORT` (default `48653`)
45
93
 
46
- MIT
94
+ Get the editor at [pixelaoi.pro](https://pixelaoi.pro).
package/index.js CHANGED
@@ -1,125 +1,467 @@
1
- #!/usr/bin/env node
2
-
3
- import { readFile, writeFile } from "node:fs/promises";
4
- import sharp from "sharp";
5
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { z } from "zod";
8
-
9
- const API_URL = (process.env.PIXELATE_API_URL || "").replace(/\/+$/, "");
10
- const API_KEY = process.env.PIXELATE_API_KEY || "";
11
- if (!API_URL || !API_KEY) {
12
- console.error("pixelate-mcp: set PIXELATE_API_URL and PIXELATE_API_KEY environment variables.");
13
- process.exit(1);
14
- }
15
-
16
- const stripDataUrl = (s) => {
17
- const i = s.indexOf(",");
18
- return s.startsWith("data:") && i !== -1 ? s.slice(i + 1) : s;
19
- };
20
- const errText = (text) => ({ content: [{ type: "text", text }], isError: true });
21
- const usd = (cents) => `$${(cents / 100).toFixed(2)}`;
22
- // One-line billing summary from a /job done response (credits_cents may be null
23
- // for unlimited subscription keys).
24
- function billingLine(res) {
25
- const price = res.price_cents != null ? `${usd(res.price_cents)}/image` : null;
26
- if (res.credits_cents == null) return price ? ` Plan: unlimited (${price}).` : "";
27
- const imgs = res.price_cents ? Math.floor(res.credits_cents / res.price_cents) : null;
28
- return ` Charged ${price}; ${usd(res.credits_cents)} credit left${imgs != null ? ` (~${imgs} images)` : ""}.`;
29
- }
30
-
31
- async function jobReq(query, body) {
32
- const res = await fetch(`${API_URL}/job?${query}`, {
33
- method: "POST",
34
- headers: body
35
- ? { "content-type": "application/octet-stream", "x-api-key": API_KEY }
36
- : { "x-api-key": API_KEY },
37
- body: body ?? undefined,
38
- });
39
- const j = await res.json().catch(() => ({}));
40
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${j.error || "request failed"}`);
41
- if (!j.success) throw new Error(j.error || "job error");
42
- return j;
43
- }
44
-
45
- const server = new McpServer({ name: "pixelate-mcp", version: "0.2.0" });
46
-
47
- server.tool(
48
- "pixelate_account",
49
- "Show your Pixelate Cloud credit balance (how much you have left) and the cost per image.",
50
- {},
51
- async () => {
52
- try {
53
- const res = await fetch(`${API_URL}/account`, { headers: { "x-api-key": API_KEY } });
54
- const j = await res.json().catch(() => ({}));
55
- if (!res.ok || !j.success) return errText(`account error (${res.status}): ${j.error || "unknown"}`);
56
- const text =
57
- j.plan === "subscription"
58
- ? `Plan: unlimited subscription. Cost per image: ${usd(j.price_per_image_cents)} (not charged on your plan).`
59
- : `Balance: $${j.credits_usd} (~${j.images_remaining} images left). Cost per image: $${j.price_per_image_usd}.`;
60
- return { content: [{ type: "text", text }] };
61
- } catch (e) {
62
- return errText(`pixelate-mcp error: ${e?.message || e}`);
63
- }
64
- },
65
- );
66
-
67
- server.tool(
68
- "pixelate_image",
69
- "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.",
70
- {
71
- image_path: z.string().optional().describe("Local path to the source image (png/jpg/webp)."),
72
- image_base64: z.string().optional().describe("Base64-encoded image (alternative to image_path)."),
73
- image_url: z.string().optional().describe("URL to fetch the image from (alternative to image_path)."),
74
- auto: z.boolean().optional().describe("Auto-detect native grid + palette (default true)."),
75
- target_width: z.number().int().positive().optional().describe("Output width; used when auto is off or no grid is detected."),
76
- target_height: z.number().int().positive().optional().describe("Output height; used when auto is off or no grid is detected."),
77
- colors: z.number().int().positive().optional().describe("Palette size; omit to auto-pick."),
78
- seed: z.number().int().optional().describe("Determinism seed (default 42)."),
79
- output_path: z.string().optional().describe("If set, save the resulting PNG to this path."),
80
- },
81
- async (a) => {
82
- try {
83
- let buf;
84
- if (a.image_base64) buf = Buffer.from(stripDataUrl(a.image_base64), "base64");
85
- else if (a.image_path) buf = await readFile(a.image_path);
86
- else if (a.image_url) buf = Buffer.from(await (await fetch(a.image_url)).arrayBuffer());
87
- else return errText("Provide one of image_path, image_base64, or image_url.");
88
-
89
- // Decode locally to raw RGBA (no Worker decode -> no decode CPU on the edge).
90
- const { data, info } = await sharp(buf).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
91
-
92
- // Start: raw bytes go in the binary body; params in the query string.
93
- const q = new URLSearchParams({ action: "start", w: String(info.width), h: String(info.height), auto: (a.auto ?? true) ? "1" : "0" });
94
- if (a.colors) q.set("colors", String(a.colors));
95
- if (a.seed != null) q.set("seed", String(a.seed));
96
- if (a.target_width) q.set("tw", String(a.target_width));
97
- if (a.target_height) q.set("th", String(a.target_height));
98
-
99
- let res = await jobReq(q.toString(), data);
100
- const jobId = res.job_id;
101
- let guard = 0;
102
- while (!res.done) {
103
- if (++guard > 1000) return errText("pixelate-mcp: job did not converge");
104
- res = await jobReq(`action=step&id=${encodeURIComponent(jobId)}`);
105
- }
106
-
107
- let summary = `Pixel art ${res.width}x${res.height}.`;
108
- if (a.output_path) {
109
- await writeFile(a.output_path, Buffer.from(res.image_base64, "base64"));
110
- summary += ` Saved to ${a.output_path}.`;
111
- }
112
- summary += billingLine(res);
113
- return {
114
- content: [
115
- { type: "text", text: summary },
116
- { type: "image", data: res.image_base64, mimeType: "image/png" },
117
- ],
118
- };
119
- } catch (e) {
120
- return errText(`pixelate-mcp error: ${e?.message || e}`);
121
- }
122
- },
123
- );
124
-
125
- await server.connect(new StdioServerTransport());
1
+ #!/usr/bin/env node
2
+ import net from "node:net";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+
8
+ const HOST = process.env.PIXELAOI_BRIDGE_HOST || "127.0.0.1";
9
+ const PORT = Number(process.env.PIXELAOI_BRIDGE_PORT || 48653);
10
+
11
+
12
+ let sock = null;
13
+ let connected = false;
14
+ let buffer = "";
15
+ let nextId = 1;
16
+ const pending = new Map();
17
+
18
+ function teardown(err) {
19
+ if (sock) {
20
+ try { sock.destroy(); } catch {}
21
+ }
22
+ sock = null;
23
+ connected = false;
24
+ buffer = "";
25
+ for (const [, p] of pending) {
26
+ clearTimeout(p.timer);
27
+ p.reject(err || new Error("bridge disconnected"));
28
+ }
29
+ pending.clear();
30
+ }
31
+
32
+ function connect() {
33
+ if (connected && sock) return Promise.resolve();
34
+ return new Promise((resolve, reject) => {
35
+ const s = net.createConnection({ host: HOST, port: PORT });
36
+ s.setNoDelay(true);
37
+ s.once("connect", () => {
38
+ sock = s;
39
+ connected = true;
40
+ resolve();
41
+ });
42
+ s.once("error", (e) => {
43
+ connected = false;
44
+ reject(
45
+ 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})`,
49
+ ),
50
+ );
51
+ });
52
+ s.on("data", (chunk) => {
53
+ buffer += chunk.toString("utf8");
54
+ let nl;
55
+ while ((nl = buffer.indexOf("\n")) !== -1) {
56
+ const line = buffer.slice(0, nl).trim();
57
+ buffer = buffer.slice(nl + 1);
58
+ if (!line) continue;
59
+ let msg;
60
+ try {
61
+ msg = JSON.parse(line);
62
+ } catch {
63
+ continue;
64
+ }
65
+ const p = pending.get(msg.id);
66
+ if (!p) continue;
67
+ pending.delete(msg.id);
68
+ clearTimeout(p.timer);
69
+ if (msg.ok) p.resolve(msg.result);
70
+ else p.reject(new Error(msg.error || "bridge error"));
71
+ }
72
+ });
73
+ s.on("close", () => teardown(new Error("bridge connection closed")));
74
+ });
75
+ }
76
+
77
+ async function call(op, args, timeoutMs = 30000) {
78
+ await connect();
79
+ const id = nextId++;
80
+ return new Promise((resolve, reject) => {
81
+ const timer = setTimeout(() => {
82
+ pending.delete(id);
83
+ reject(new Error(`bridge op '${op}' timed out after ${timeoutMs}ms`));
84
+ }, timeoutMs);
85
+ pending.set(id, { resolve, reject, timer });
86
+ try {
87
+ sock.write(JSON.stringify({ id, op, args: args ?? null }) + "\n");
88
+ } catch (e) {
89
+ clearTimeout(timer);
90
+ pending.delete(id);
91
+ reject(e);
92
+ }
93
+ });
94
+ }
95
+
96
+ const errText = (e) => ({
97
+ content: [{ type: "text", text: `pixelaoi-mcp error: ${e?.message || e}` }],
98
+ isError: true,
99
+ });
100
+ const stripDataUrl = (s) => {
101
+ const i = s.indexOf(",");
102
+ return s.startsWith("data:") && i !== -1 ? s.slice(i + 1) : s;
103
+ };
104
+
105
+ const server = new McpServer({ name: "pixelate-mcp", version: "0.3.1" });
106
+
107
+ server.tool(
108
+ "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
+ }
118
+ },
119
+ );
120
+
121
+ server.tool(
122
+ "get_canvas_image",
123
+ "See the current canvas: returns the composited current frame as a PNG image.",
124
+ {},
125
+ 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
+ }
137
+ },
138
+ );
139
+
140
+ server.tool(
141
+ "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") },
144
+ 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
+ }
151
+ },
152
+ );
153
+
154
+ server.tool(
155
+ "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
+ {
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."),
165
+ },
166
+ 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
+ }
181
+ },
182
+ );
183
+
184
+ server.tool(
185
+ "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
+ {
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."),
193
+ },
194
+ 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
+ }
207
+ },
208
+ );
209
+
210
+ server.tool(
211
+ "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
+ {
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."),
226
+ },
227
+ 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);
262
+ }
263
+ },
264
+ );
265
+
266
+ server.tool(
267
+ "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
+ {
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."),
277
+ },
278
+ 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
+ }
296
+ },
297
+ );
298
+
299
+ server.tool(
300
+ "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
+ {
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
+ }
315
+ },
316
+ );
317
+
318
+ server.tool(
319
+ "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
+ }
329
+ },
330
+ );
331
+
332
+ server.tool(
333
+ "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
+ }
343
+ },
344
+ );
345
+
346
+ server.tool(
347
+ "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
+ {
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)."),
353
+ },
354
+ 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
+ }
364
+ },
365
+ );
366
+
367
+ server.tool(
368
+ "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
+ {
371
+ from: z.number().int().describe("Source node id."),
372
+ to: z.number().int().describe("Destination node id."),
373
+ },
374
+ 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
+ }
381
+ },
382
+ );
383
+
384
+ server.tool(
385
+ "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
+ {
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."),
391
+ },
392
+ 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
+ }
401
+ },
402
+ );
403
+
404
+ server.tool(
405
+ "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
+ {
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."),
410
+ },
411
+ 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
+ }
418
+ },
419
+ );
420
+
421
+ server.tool(
422
+ "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
+ {
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)."),
428
+ },
429
+ 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
+ }
436
+ },
437
+ );
438
+
439
+ server.tool(
440
+ "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.") },
443
+ 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
+ }
450
+ },
451
+ );
452
+
453
+ server.tool(
454
+ "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
+ {},
457
+ 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
+ }
464
+ },
465
+ );
466
+
467
+ await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pixelate-mcp",
3
- "version": "0.2.0",
4
- "description": "MCP server: turn any image into grid-perfect pixel art via the Pixelate Cloud API. Works in Codex, Claude Desktop, Cursor.",
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
5
  "type": "module",
6
6
  "bin": {
7
7
  "pixelate-mcp": "index.js"
@@ -11,17 +11,16 @@
11
11
  "README.md",
12
12
  "LICENSE"
13
13
  ],
14
+ "homepage": "https://pixelaoi.pro",
14
15
  "keywords": [
15
16
  "mcp",
16
17
  "model-context-protocol",
17
18
  "pixel-art",
19
+ "pixelaoi",
18
20
  "pixelate",
19
21
  "codex",
20
- "claude",
21
- "cursor",
22
- "aseprite"
22
+ "claude"
23
23
  ],
24
- "homepage": "https://pixelaoi.pro",
25
24
  "license": "MIT",
26
25
  "author": "PixelAOI",
27
26
  "engines": {
@@ -29,7 +28,6 @@
29
28
  },
30
29
  "dependencies": {
31
30
  "@modelcontextprotocol/sdk": "^1.12.0",
32
- "sharp": "^0.33.5",
33
31
  "zod": "^3.23.8"
34
32
  }
35
33
  }