@pixel-normal-edit/mcp 2.0.4 → 2.0.5
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.
- package/core/command-bus.js +49 -0
- package/core/firebase.js +26 -0
- package/core/server.js +31 -0
- package/domains/art-tree/index.js +27 -0
- package/domains/art-tree/lessons.js +193 -0
- package/domains/art-tree/shapes.js +197 -0
- package/domains/art-tree/tools.js +179 -0
- package/index.js +36 -550
- package/package.json +1 -1
- package/tools/anchors.js +27 -0
- package/tools/animation.js +60 -0
- package/tools/canvas.js +38 -0
- package/tools/colors.js +24 -0
- package/tools/drawing.js +80 -0
- package/tools/filters.js +21 -0
- package/tools/health.js +16 -0
- package/tools/history.js +21 -0
- package/tools/index.js +42 -0
- package/tools/modes.js +27 -0
- package/tools/query.js +48 -0
- package/tools/region.js +24 -0
- package/tools/sprite.js +43 -0
- package/tools/workspace.js +47 -0
- package/transport/http.js +86 -0
- package/transport/stdio.js +21 -0
package/index.js
CHANGED
|
@@ -1,563 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* MCP Firebase Bridge
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Each tool maps 1:1 to a command-bus action with full schema validation.
|
|
3
|
+
* MCP Firebase Bridge v3 — Modular Architecture
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* Entry point for the Pixel Normal Edit MCP server.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* index.js → Entry point: initializes server, registers modules, starts transport
|
|
9
|
+
* core/ → Firebase, command bus, server factory
|
|
10
|
+
* tools/ → Core tools (canvas, workspace, animation, drawing, etc.)
|
|
11
|
+
* transport/ → stdio and HTTP transports
|
|
12
|
+
* domains/ → Domain-specific modules (art-tree, etc.)
|
|
13
|
+
*
|
|
14
|
+
* Design principles:
|
|
15
|
+
* - index.js is kept minimal — it only coordinates initialization
|
|
16
|
+
* - All tool logic lives in tools/ and domains/
|
|
17
|
+
* - New domains can be added as folders in domains/ without modifying core
|
|
18
|
+
* - Every tool maps 1:1 to a Firebase command-bus action
|
|
19
|
+
* - No custom drawing logic — all drawing uses existing primitives
|
|
20
|
+
*
|
|
21
|
+
* Module naming convention:
|
|
22
|
+
* - Core tools: lowercase with underscores (e.g. draw_rect, query_snapshot)
|
|
23
|
+
* - Domain tools: domain_prefix + name (e.g. art_tree_draw_square)
|
|
24
|
+
* ============================================================================
|
|
13
25
|
*/
|
|
26
|
+
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
14
27
|
|
|
15
|
-
const {
|
|
16
|
-
const {
|
|
17
|
-
const {
|
|
18
|
-
const { z } = require('zod');
|
|
19
|
-
const { initializeApp } = require('firebase/app');
|
|
20
|
-
const { getFirestore, doc, setDoc, onSnapshot } = require('firebase/firestore');
|
|
21
|
-
const dotenv = require('dotenv');
|
|
22
|
-
const path = require('path');
|
|
23
|
-
const crypto = require('crypto');
|
|
24
|
-
const http = require('http');
|
|
25
|
-
|
|
26
|
-
dotenv.config({ path: path.join(__dirname, '..', '.env') });
|
|
27
|
-
|
|
28
|
-
const db = getFirestore(initializeApp({
|
|
29
|
-
apiKey: 'AIzaSyBSrvCt58Jhsh14wbC2bD2KLFUUVbAVim0',
|
|
30
|
-
authDomain: 'pixel-normal-edit.firebaseapp.com',
|
|
31
|
-
projectId: 'pixel-normal-edit',
|
|
32
|
-
storageBucket: 'pixel-normal-edit.firebasestorage.app',
|
|
33
|
-
messagingSenderId: '397075334229',
|
|
34
|
-
appId: '1:397075334229:web:b02eede3fc7b41d02f80dc',
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
|
-
const SESSION = process.argv[2] || process.env.MCP_SESSION || 'default-session';
|
|
38
|
-
const TIMEOUT = parseInt(process.env.MCP_TIMEOUT || '20000');
|
|
39
|
-
|
|
40
|
-
// ── Core: send any JSON command → wait for browser response ───────────────
|
|
41
|
-
async function sendCommand(payload) {
|
|
42
|
-
const id = crypto.randomUUID();
|
|
43
|
-
const ref = doc(db, 'mcp_sessions', SESSION, 'commands', id);
|
|
44
|
-
await setDoc(ref, { ...payload, status: 'pending', timestamp: Date.now() });
|
|
45
|
-
|
|
46
|
-
return new Promise((resolve) => {
|
|
47
|
-
const timer = setTimeout(() => {
|
|
48
|
-
unsub();
|
|
49
|
-
resolve({ isError: true, content: [{ type: 'text', text: `⏱ Timeout (${TIMEOUT}ms). Is the browser tab open with session: ${SESSION}?` }] });
|
|
50
|
-
}, TIMEOUT);
|
|
51
|
-
|
|
52
|
-
const unsub = onSnapshot(ref, (snap) => {
|
|
53
|
-
const d = snap.data();
|
|
54
|
-
if (!d) return;
|
|
55
|
-
if (d.status === 'success') {
|
|
56
|
-
clearTimeout(timer); unsub();
|
|
57
|
-
const out = d.result !== undefined ? JSON.stringify(d.result, null, 2) : '✓ Done';
|
|
58
|
-
resolve({ content: [{ type: 'text', text: out }] });
|
|
59
|
-
} else if (d.status === 'error') {
|
|
60
|
-
clearTimeout(timer); unsub();
|
|
61
|
-
resolve({ isError: true, content: [{ type: 'text', text: `❌ ${d.error}` }] });
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ── Tool factory: single-command tool ─────────────────────────────────────
|
|
68
|
-
function tool(server, name, desc, schema, mapToCmd) {
|
|
69
|
-
server.tool(name, desc, schema, async (params) => sendCommand(mapToCmd(params)));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
73
|
-
const server = new McpServer({ name: 'PixelNormalEdit', version: '2.0.0' });
|
|
74
|
-
|
|
75
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
76
|
-
// GROUP 1: CANVAS & SETUP
|
|
77
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
78
|
-
tool(server, 'canvas_get_size',
|
|
79
|
-
'Get current canvas dimensions {width, height}',
|
|
80
|
-
{},
|
|
81
|
-
() => ({ action: 'getSize' }));
|
|
82
|
-
|
|
83
|
-
tool(server, 'canvas_resize',
|
|
84
|
-
'Resize canvas to new dimensions. mode=clear resets pixels, extend keeps them.',
|
|
85
|
-
{
|
|
86
|
-
width: z.number().int().min(1).max(1024).describe('New width in pixels'),
|
|
87
|
-
height: z.number().int().min(1).max(1024).describe('New height in pixels'),
|
|
88
|
-
mode: z.enum(['clear', 'extend', 'fit']).default('clear').describe('How to handle existing content'),
|
|
89
|
-
dx: z.number().int().default(0).describe('X offset for content when extending'),
|
|
90
|
-
dy: z.number().int().default(0).describe('Y offset for content when extending')
|
|
91
|
-
},
|
|
92
|
-
(p) => ({ action: 'resize', ...p }));
|
|
93
|
-
|
|
94
|
-
tool(server, 'canvas_clear',
|
|
95
|
-
'Erase all pixels on the current canvas frame',
|
|
96
|
-
{},
|
|
97
|
-
() => ({ action: 'clear' }));
|
|
98
|
-
|
|
99
|
-
tool(server, 'canvas_trim',
|
|
100
|
-
'Auto-trim transparent borders from canvas',
|
|
101
|
-
{},
|
|
102
|
-
() => ({ action: 'trim' }));
|
|
103
|
-
|
|
104
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
105
|
-
// GROUP 2: WORKSPACE / TABS
|
|
106
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
107
|
-
tool(server, 'workspace_list_tabs',
|
|
108
|
-
'List all open document tabs with their IDs and names',
|
|
109
|
-
{},
|
|
110
|
-
() => ({ action: 'listTabs' }));
|
|
111
|
-
|
|
112
|
-
tool(server, 'workspace_get_active_tab',
|
|
113
|
-
'Get the currently active tab ID',
|
|
114
|
-
{},
|
|
115
|
-
() => ({ action: 'getActiveTabId' }));
|
|
116
|
-
|
|
117
|
-
tool(server, 'workspace_create_tab',
|
|
118
|
-
'Create a new blank canvas tab',
|
|
119
|
-
{
|
|
120
|
-
name: z.string().optional().describe('Tab name'),
|
|
121
|
-
width: z.number().int().default(32).describe('Canvas width'),
|
|
122
|
-
height: z.number().int().default(32).describe('Canvas height')
|
|
123
|
-
},
|
|
124
|
-
(p) => ({ action: 'createTab', ...p }));
|
|
125
|
-
|
|
126
|
-
tool(server, 'workspace_switch_tab',
|
|
127
|
-
'Switch to a different tab by ID (get IDs from workspace_list_tabs)',
|
|
128
|
-
{ tabId: z.string().describe('Tab ID to switch to') },
|
|
129
|
-
(p) => ({ action: 'switchTab', tabId: p.tabId }));
|
|
130
|
-
|
|
131
|
-
tool(server, 'workspace_rename_tab',
|
|
132
|
-
'Rename a tab',
|
|
133
|
-
{ tabId: z.string(), name: z.string() },
|
|
134
|
-
(p) => ({ action: 'renameTab', ...p }));
|
|
135
|
-
|
|
136
|
-
tool(server, 'workspace_save',
|
|
137
|
-
'Quick-save the current workspace',
|
|
138
|
-
{},
|
|
139
|
-
() => ({ action: 'quickSave' }));
|
|
140
|
-
|
|
141
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
142
|
-
// GROUP 3: ANIMATION FRAMES
|
|
143
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
144
|
-
tool(server, 'animation_enable',
|
|
145
|
-
'Enable or disable animation mode. When enabled, the canvas has multiple frames.',
|
|
146
|
-
{ enabled: z.boolean().describe('true to enable, false to disable') },
|
|
147
|
-
(p) => ({ action: 'setAnimationMode', enabled: p.enabled }));
|
|
148
|
-
|
|
149
|
-
tool(server, 'animation_add_frame',
|
|
150
|
-
'Append a new blank frame to the animation',
|
|
151
|
-
{},
|
|
152
|
-
() => ({ action: 'addFrame' }));
|
|
153
|
-
|
|
154
|
-
tool(server, 'animation_go_to_frame',
|
|
155
|
-
'Switch to editing a specific frame by index (0-based)',
|
|
156
|
-
{ index: z.number().int().min(0).describe('Frame index') },
|
|
157
|
-
(p) => ({ action: 'goToFrame', index: p.index }));
|
|
158
|
-
|
|
159
|
-
tool(server, 'animation_ensure_frame',
|
|
160
|
-
'Make sure the animation has at least (index+1) frames, then switch to that frame',
|
|
161
|
-
{ index: z.number().int().min(0).describe('Target frame index') },
|
|
162
|
-
(p) => ({ action: 'ensureFrame', index: p.index }));
|
|
163
|
-
|
|
164
|
-
tool(server, 'animation_get_info',
|
|
165
|
-
'Get current frame count and active frame index',
|
|
166
|
-
{},
|
|
167
|
-
async () => {
|
|
168
|
-
const [count, idx] = await Promise.all([
|
|
169
|
-
sendCommand({ action: 'getFrameCount' }),
|
|
170
|
-
sendCommand({ action: 'getActiveFrameIndex' }),
|
|
171
|
-
]);
|
|
172
|
-
return { content: [{ type: 'text', text: JSON.stringify({ frameCount: count, activeIndex: idx }) }] };
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
tool(server, 'animation_remove_frame',
|
|
176
|
-
'Remove frame at the given index',
|
|
177
|
-
{ index: z.number().int().min(0) },
|
|
178
|
-
(p) => ({ action: 'removeFrame', index: p.index }));
|
|
179
|
-
|
|
180
|
-
tool(server, 'animation_reorder_frame',
|
|
181
|
-
'Move a frame from one position to another',
|
|
182
|
-
{ from: z.number().int().min(0), to: z.number().int().min(0) },
|
|
183
|
-
(p) => ({ action: 'reorderFrame', from: p.from, to: p.to }));
|
|
184
|
-
|
|
185
|
-
tool(server, 'animation_compare_frames',
|
|
186
|
-
'Get list of pixel differences between two frames',
|
|
187
|
-
{ frameIndex1: z.number().int().min(0), frameIndex2: z.number().int().min(0) },
|
|
188
|
-
(p) => ({ action: 'getFrameDifferences', ...p }));
|
|
189
|
-
|
|
190
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
191
|
-
// GROUP 4: DRAWING PRIMITIVES
|
|
192
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
193
|
-
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/).describe('Hex color e.g. #ff0000');
|
|
194
|
-
|
|
195
|
-
tool(server, 'draw_pixel',
|
|
196
|
-
'Set a single pixel to a color',
|
|
197
|
-
{
|
|
198
|
-
x: z.number().int().describe('X coordinate'),
|
|
199
|
-
y: z.number().int().describe('Y coordinate'),
|
|
200
|
-
color: hexColor
|
|
201
|
-
},
|
|
202
|
-
(p) => ({ action: 'drawPixel', ...p }));
|
|
203
|
-
|
|
204
|
-
tool(server, 'draw_erase',
|
|
205
|
-
'Erase a single pixel (make transparent)',
|
|
206
|
-
{ x: z.number().int(), y: z.number().int() },
|
|
207
|
-
(p) => ({ action: 'erasePixel', ...p }));
|
|
208
|
-
|
|
209
|
-
tool(server, 'draw_line',
|
|
210
|
-
'Draw a straight line using Bresenham algorithm',
|
|
211
|
-
{
|
|
212
|
-
x0: z.number().int(), y0: z.number().int(),
|
|
213
|
-
x1: z.number().int(), y1: z.number().int(),
|
|
214
|
-
color: hexColor
|
|
215
|
-
},
|
|
216
|
-
(p) => ({ action: 'drawLine', ...p }));
|
|
217
|
-
|
|
218
|
-
tool(server, 'draw_rect',
|
|
219
|
-
'Draw a rectangle (outline or filled)',
|
|
220
|
-
{
|
|
221
|
-
x: z.number().int().describe('Top-left X'),
|
|
222
|
-
y: z.number().int().describe('Top-left Y'),
|
|
223
|
-
w: z.number().int().min(1).describe('Width'),
|
|
224
|
-
h: z.number().int().min(1).describe('Height'),
|
|
225
|
-
color: hexColor,
|
|
226
|
-
filled: z.boolean().default(false).describe('Fill interior?')
|
|
227
|
-
},
|
|
228
|
-
(p) => ({ action: 'drawRect', ...p }));
|
|
229
|
-
|
|
230
|
-
tool(server, 'draw_circle',
|
|
231
|
-
'Draw a circle (outline or filled)',
|
|
232
|
-
{
|
|
233
|
-
cx: z.number().int().describe('Center X'),
|
|
234
|
-
cy: z.number().int().describe('Center Y'),
|
|
235
|
-
r: z.number().int().min(1).describe('Radius'),
|
|
236
|
-
color: hexColor,
|
|
237
|
-
filled: z.boolean().default(false)
|
|
238
|
-
},
|
|
239
|
-
(p) => ({ action: 'drawCircle', ...p }));
|
|
28
|
+
const { createServer } = require('./core/server');
|
|
29
|
+
const { registerAll: registerCoreTools } = require('./tools/index');
|
|
30
|
+
const { registerAll: registerArtTree } = require('./domains/art-tree/index');
|
|
240
31
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
{
|
|
244
|
-
cx: z.number().int(), cy: z.number().int(),
|
|
245
|
-
rx: z.number().int().min(1).describe('Horizontal radius'),
|
|
246
|
-
ry: z.number().int().min(1).describe('Vertical radius'),
|
|
247
|
-
color: hexColor,
|
|
248
|
-
filled: z.boolean().default(false)
|
|
249
|
-
},
|
|
250
|
-
(p) => ({ action: 'drawEllipse', ...p }));
|
|
32
|
+
// ── Initialize ────────────────────────────────────────────────────────────
|
|
33
|
+
const server = createServer();
|
|
251
34
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
{
|
|
255
|
-
points: z.array(z.object({ x: z.number().int(), y: z.number().int() })).min(3).describe('Array of {x,y} vertices'),
|
|
256
|
-
color: hexColor,
|
|
257
|
-
filled: z.boolean().default(false)
|
|
258
|
-
},
|
|
259
|
-
(p) => ({ action: 'drawPolygon', ...p }));
|
|
35
|
+
// ── Register Core Tools ───────────────────────────────────────────────────
|
|
36
|
+
registerCoreTools(server);
|
|
260
37
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
(p) => ({ action: 'fill', ...p }));
|
|
38
|
+
// ── Register Domain Modules ───────────────────────────────────────────────
|
|
39
|
+
registerArtTree(server);
|
|
40
|
+
// Future domains: registerPixelArt(server), registerCharacterDrawing(server), etc.
|
|
265
41
|
|
|
266
|
-
|
|
267
|
-
'Draw a rectangle filled with a smooth gradient',
|
|
268
|
-
{
|
|
269
|
-
x: z.number().int(), y: z.number().int(),
|
|
270
|
-
w: z.number().int().min(1), h: z.number().int().min(1),
|
|
271
|
-
colorFrom: hexColor.describe('Start color'),
|
|
272
|
-
colorTo: hexColor.describe('End color'),
|
|
273
|
-
direction: z.enum(['h', 'v']).default('h').describe('h=horizontal, v=vertical')
|
|
274
|
-
},
|
|
275
|
-
(p) => ({ action: 'drawGradientRect', ...p }));
|
|
276
|
-
|
|
277
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
278
|
-
// GROUP 5: BULK OPERATIONS
|
|
279
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
280
|
-
tool(server, 'bulk_replace_color',
|
|
281
|
-
'Replace ALL pixels of one color with another across the entire canvas',
|
|
282
|
-
{
|
|
283
|
-
from: hexColor.describe('Color to replace'),
|
|
284
|
-
to: hexColor.describe('New color')
|
|
285
|
-
},
|
|
286
|
-
(p) => ({ action: 'replaceColor', from: p.from, to: p.to }));
|
|
287
|
-
|
|
288
|
-
tool(server, 'bulk_flood_fill_all',
|
|
289
|
-
'Flood-fill every disconnected region of a given color',
|
|
290
|
-
{
|
|
291
|
-
color: hexColor.describe('Color to replace'),
|
|
292
|
-
to: hexColor.describe('Replacement color')
|
|
293
|
-
},
|
|
294
|
-
(p) => ({ action: 'floodFillAll', color: p.color, to: p.to }));
|
|
295
|
-
|
|
296
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
297
|
-
// GROUP 6: SPRITE / STAMP SYSTEM
|
|
298
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
299
|
-
tool(server, 'sprite_draw',
|
|
300
|
-
`Draw a sprite using ASCII art + color palette. Each character in the data array maps to a color.
|
|
301
|
-
EXAMPLE:
|
|
302
|
-
palette: { "H": "#ffcc99", "B": "#1565c0", ".": null }
|
|
303
|
-
data: [ "..H..", ".BBB.", "..H.." ]
|
|
304
|
-
This is the most efficient way to draw complex shapes — replaces dozens of drawPixel calls.`,
|
|
305
|
-
{
|
|
306
|
-
x: z.number().int().default(0).describe('Top-left X offset'),
|
|
307
|
-
y: z.number().int().default(0).describe('Top-left Y offset'),
|
|
308
|
-
palette: z.record(z.string().length(1), z.string().nullable()).describe('Char→hex map. null = transparent/skip'),
|
|
309
|
-
data: z.array(z.string()).min(1).describe('Rows of ASCII art characters')
|
|
310
|
-
},
|
|
311
|
-
(p) => ({ action: 'drawSprite', ...p }));
|
|
312
|
-
|
|
313
|
-
tool(server, 'sprite_save_stamp',
|
|
314
|
-
'Save a named sprite for reuse across frames. Use sprite_use_stamp to place it.',
|
|
315
|
-
{
|
|
316
|
-
name: z.string().describe('Unique stamp name'),
|
|
317
|
-
palette: z.record(z.string().length(1), z.string().nullable()),
|
|
318
|
-
data: z.array(z.string()).min(1)
|
|
319
|
-
},
|
|
320
|
-
(p) => ({ action: 'saveStamp', ...p }));
|
|
321
|
-
|
|
322
|
-
tool(server, 'sprite_use_stamp',
|
|
323
|
-
'Place a previously saved stamp at a position. Optionally override palette colors.',
|
|
324
|
-
{
|
|
325
|
-
name: z.string().describe('Stamp name (from sprite_save_stamp)'),
|
|
326
|
-
x: z.number().int().default(0),
|
|
327
|
-
y: z.number().int().default(0),
|
|
328
|
-
palette: z.record(z.string().length(1), z.string().nullable()).optional().describe('Override specific palette colors')
|
|
329
|
-
},
|
|
330
|
-
(p) => ({ action: 'useStamp', ...p }));
|
|
331
|
-
|
|
332
|
-
tool(server, 'sprite_list_stamps',
|
|
333
|
-
'List all saved stamp names',
|
|
334
|
-
{},
|
|
335
|
-
() => ({ action: 'listStamps' }));
|
|
336
|
-
|
|
337
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
338
|
-
// GROUP 7: REGION CLIPBOARD
|
|
339
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
340
|
-
tool(server, 'region_copy',
|
|
341
|
-
'Copy a rectangular region of pixels to an internal clipboard',
|
|
342
|
-
{
|
|
343
|
-
x: z.number().int().default(0), y: z.number().int().default(0),
|
|
344
|
-
w: z.number().int().min(1).describe('Width of region'),
|
|
345
|
-
h: z.number().int().min(1).describe('Height of region')
|
|
346
|
-
},
|
|
347
|
-
(p) => ({ action: 'copyRegion', ...p }));
|
|
348
|
-
|
|
349
|
-
tool(server, 'region_paste',
|
|
350
|
-
'Paste the clipboard region at (x,y). Defaults to original position.',
|
|
351
|
-
{
|
|
352
|
-
x: z.number().int().optional().describe('X destination (default: original X)'),
|
|
353
|
-
y: z.number().int().optional().describe('Y destination (default: original Y)')
|
|
354
|
-
},
|
|
355
|
-
(p) => ({ action: 'pasteRegion', ...p }));
|
|
356
|
-
|
|
357
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
358
|
-
// GROUP 8: FILTERS
|
|
359
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
360
|
-
tool(server, 'filter_apply',
|
|
361
|
-
`Apply a visual filter to the canvas (or a region).
|
|
362
|
-
Types: brightness (value: -255 to 255), invert, grayscale, hue-rotate (value: degrees 0-360)`,
|
|
363
|
-
{
|
|
364
|
-
type: z.enum(['brightness', 'invert', 'grayscale', 'hue-rotate']),
|
|
365
|
-
value: z.number().optional().describe('Parameter: brightness=-255..255, hue-rotate=0..360'),
|
|
366
|
-
x: z.number().int().optional(), y: z.number().int().optional(),
|
|
367
|
-
w: z.number().int().optional(), h: z.number().int().optional()
|
|
368
|
-
},
|
|
369
|
-
(p) => ({ action: 'applyFilter', ...p }));
|
|
370
|
-
|
|
371
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
372
|
-
// GROUP 9: ANCHORS
|
|
373
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
374
|
-
tool(server, 'anchor_set',
|
|
375
|
-
'Set a named coordinate anchor for relative drawing. E.g. anchor "head" at (20,5) then draw relative to it.',
|
|
376
|
-
{
|
|
377
|
-
name: z.string().describe('Anchor name'),
|
|
378
|
-
x: z.number().int(), y: z.number().int()
|
|
379
|
-
},
|
|
380
|
-
(p) => ({ action: 'setAnchor', ...p }));
|
|
381
|
-
|
|
382
|
-
tool(server, 'anchor_get',
|
|
383
|
-
'Get position of a named anchor',
|
|
384
|
-
{ name: z.string() },
|
|
385
|
-
(p) => ({ action: 'getAnchor', name: p.name }));
|
|
386
|
-
|
|
387
|
-
tool(server, 'anchor_list',
|
|
388
|
-
'List all named anchors',
|
|
389
|
-
{},
|
|
390
|
-
() => ({ action: 'listAnchors' }));
|
|
391
|
-
|
|
392
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
393
|
-
// GROUP 10: QUERY & VISUAL FEEDBACK ← Critical for AI agents
|
|
394
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
395
|
-
tool(server, 'query_snapshot',
|
|
396
|
-
`Get an ASCII art representation of the current canvas frame. Use this to "see" what you've drawn before continuing.
|
|
397
|
-
Returns: { ascii, legend, width, height } where each symbol in ascii maps to a hex color via legend.
|
|
398
|
-
Increase scale to get a smaller grid (scale=2 means 1 char = 2×2 pixels).`,
|
|
399
|
-
{
|
|
400
|
-
scale: z.number().int().min(1).max(8).default(1).describe('Downscale factor (1=full resolution)'),
|
|
401
|
-
maxColors: z.number().int().min(2).max(32).default(12).describe('Max distinct colors in legend')
|
|
402
|
-
},
|
|
403
|
-
(p) => ({ action: 'querySnapshot', ...p }));
|
|
404
|
-
|
|
405
|
-
tool(server, 'query_bounding_box',
|
|
406
|
-
'Get the bounding box of all non-transparent pixels {minX, minY, maxX, maxY, width, height}',
|
|
407
|
-
{},
|
|
408
|
-
() => ({ action: 'query', type: 'getBoundingBox' }));
|
|
409
|
-
|
|
410
|
-
tool(server, 'query_palette',
|
|
411
|
-
'Get list of all distinct colors used on the canvas',
|
|
412
|
-
{},
|
|
413
|
-
() => ({ action: 'query', type: 'getPalette' }));
|
|
414
|
-
|
|
415
|
-
tool(server, 'query_pixel',
|
|
416
|
-
'Get the color of a specific pixel. Returns hex string or null if transparent.',
|
|
417
|
-
{ x: z.number().int(), y: z.number().int() },
|
|
418
|
-
(p) => ({ action: 'getPixel', x: p.x, y: p.y }));
|
|
419
|
-
|
|
420
|
-
tool(server, 'query_export_image',
|
|
421
|
-
`Export the current frame as a base64 PNG data URL.
|
|
422
|
-
Use this to visually verify your work — paste the dataUrl into an image viewer.
|
|
423
|
-
The dataUrl starts with "data:image/png;base64,..."`,
|
|
424
|
-
{ format: z.enum(['png', 'webp', 'jpeg']).default('png') },
|
|
425
|
-
(p) => ({ action: 'exportBase64', format: p.format }));
|
|
426
|
-
|
|
427
|
-
tool(server, 'query_document_state',
|
|
428
|
-
'Get full document state: tab name, canvas size, animation info, undo availability',
|
|
429
|
-
{},
|
|
430
|
-
() => ({ action: 'query', type: 'getDocumentState' }));
|
|
431
|
-
|
|
432
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
433
|
-
// GROUP 11: HISTORY
|
|
434
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
435
|
-
tool(server, 'history_undo',
|
|
436
|
-
'Undo the last drawing action',
|
|
437
|
-
{},
|
|
438
|
-
() => ({ action: 'undo' }));
|
|
439
|
-
|
|
440
|
-
tool(server, 'history_redo',
|
|
441
|
-
'Redo the last undone action',
|
|
442
|
-
{},
|
|
443
|
-
() => ({ action: 'redo' }));
|
|
444
|
-
|
|
445
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
446
|
-
// GROUP 12: MODES
|
|
447
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
448
|
-
tool(server, 'mode_set_mirror',
|
|
449
|
-
'Enable/disable mirror drawing mode (symmetric left-right)',
|
|
450
|
-
{ enabled: z.boolean() },
|
|
451
|
-
(p) => ({ action: 'setMirror', enabled: p.enabled }));
|
|
452
|
-
|
|
453
|
-
tool(server, 'mode_set_onion_skin',
|
|
454
|
-
'Enable/disable onion skin (shows previous frame as ghost)',
|
|
455
|
-
{ enabled: z.boolean() },
|
|
456
|
-
(p) => ({ action: 'setOnionSkin', enabled: p.enabled }));
|
|
457
|
-
|
|
458
|
-
tool(server, 'mode_set_grid',
|
|
459
|
-
'Show/hide the pixel grid overlay',
|
|
460
|
-
{ enabled: z.boolean() },
|
|
461
|
-
(p) => ({ action: 'setGrid', enabled: p.enabled }));
|
|
462
|
-
|
|
463
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
464
|
-
// GROUP 13: COLOR
|
|
465
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
466
|
-
tool(server, 'color_set',
|
|
467
|
-
'Set the primary and/or secondary color',
|
|
468
|
-
{ primary: hexColor.optional(), secondary: hexColor.optional() },
|
|
469
|
-
(p) => ({ action: 'setColor', ...p }));
|
|
470
|
-
|
|
471
|
-
tool(server, 'color_get',
|
|
472
|
-
'Get current primary and secondary colors',
|
|
473
|
-
{},
|
|
474
|
-
() => ({ action: 'getColor' }));
|
|
475
|
-
|
|
476
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
477
|
-
// HEALTH CHECK
|
|
478
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
479
|
-
tool(server, 'ping',
|
|
480
|
-
'Check if the browser editor is connected and responding',
|
|
481
|
-
{},
|
|
482
|
-
() => ({ action: 'ping' }));
|
|
483
|
-
|
|
484
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
485
|
-
// MODE: stdio (default) OR http (when --http flag or HTTP_PORT env is set)
|
|
486
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// ── Start Transport ───────────────────────────────────────────────────────
|
|
487
43
|
const HTTP_PORT = process.env.HTTP_PORT || (process.argv.includes('--http') ? 3456 : null);
|
|
488
44
|
|
|
489
|
-
async function startStdio() {
|
|
490
|
-
const transport = new StdioServerTransport();
|
|
491
|
-
await server.connect(transport);
|
|
492
|
-
// Disabled console.error output because some MCP clients merge stdout and stderr,
|
|
493
|
-
// causing JSON parsing errors (e.g. invalid character 'â' looking for beginning of value)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
async function startHttp(port) {
|
|
497
|
-
// Each HTTP request gets its own transport instance (stateless)
|
|
498
|
-
const app = http.createServer(async (req, res) => {
|
|
499
|
-
// CORS for browser tools
|
|
500
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
501
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
|
|
502
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id');
|
|
503
|
-
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
504
|
-
|
|
505
|
-
if (req.url === '/health') {
|
|
506
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
507
|
-
res.end(JSON.stringify({ ok: true, session: SESSION, ts: Date.now() }));
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
if (req.url !== '/mcp') {
|
|
512
|
-
res.writeHead(404); res.end('Use /mcp'); return;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Read body
|
|
516
|
-
let body = '';
|
|
517
|
-
req.on('data', chunk => body += chunk);
|
|
518
|
-
req.on('end', async () => {
|
|
519
|
-
const transport = new StreamableHTTPServerTransport({
|
|
520
|
-
sessionIdGenerator: () => crypto.randomUUID(),
|
|
521
|
-
enableJsonResponse: true,
|
|
522
|
-
});
|
|
523
|
-
const freshServer = buildServer(); // new McpServer instance per request
|
|
524
|
-
await freshServer.connect(transport);
|
|
525
|
-
const mockReq = { method: req.method, headers: req.headers, body: JSON.parse(body || '{}') };
|
|
526
|
-
await transport.handleRequest(mockReq, res, JSON.parse(body || '{}'));
|
|
527
|
-
});
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
app.listen(port, () => {
|
|
531
|
-
console.log(`\nPixel Normal Edit MCP HTTP Server`);
|
|
532
|
-
console.log(` Endpoint : http://localhost:${port}/mcp`);
|
|
533
|
-
console.log(` Health : http://localhost:${port}/health`);
|
|
534
|
-
console.log(` Session : ${SESSION}`);
|
|
535
|
-
console.log(` Editor : http://localhost:5173?mcp_session=${SESSION}`);
|
|
536
|
-
console.log(`\n── AI config (paste into mcp_config.json) ──`);
|
|
537
|
-
console.log(JSON.stringify({
|
|
538
|
-
mcpServers: {
|
|
539
|
-
'pixel-normal-edit': {
|
|
540
|
-
command: 'npx',
|
|
541
|
-
args: ['mcp-remote', `http://localhost:${port}/mcp`]
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}, null, 2));
|
|
545
|
-
console.log('────────────────────────────────────────────\n');
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// ── Entry point ────────────────────────────────────────────────────────────
|
|
550
45
|
if (HTTP_PORT) {
|
|
551
|
-
|
|
552
|
-
function buildServer() {
|
|
553
|
-
// Re-use same tool registrations on a new McpServer instance
|
|
554
|
-
const s = new McpServer({ name: 'PixelNormalEdit', version: '2.0.0' });
|
|
555
|
-
// Copy all tools by re-running the tool() factory against the new server
|
|
556
|
-
// (simplified: just re-export the whole module would be cleaner — for now proxy)
|
|
557
|
-
s._tools = server._tools;
|
|
558
|
-
return s;
|
|
559
|
-
}
|
|
560
|
-
startHttp(parseInt(HTTP_PORT)).catch(console.error);
|
|
46
|
+
require('./transport/http').start(parseInt(HTTP_PORT)).catch(console.error);
|
|
561
47
|
} else {
|
|
562
|
-
|
|
563
|
-
}
|
|
48
|
+
require('./transport/stdio').start(server).catch(console.error);
|
|
49
|
+
}
|
package/package.json
CHANGED
package/tools/anchors.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* anchors.js — Coordinate Anchor tools
|
|
4
|
+
*
|
|
5
|
+
* Tools: anchor_set, anchor_get, anchor_list
|
|
6
|
+
*/
|
|
7
|
+
const { z } = require('zod');
|
|
8
|
+
const { registerTool } = require('../core/server');
|
|
9
|
+
|
|
10
|
+
function register(server) {
|
|
11
|
+
registerTool(server, 'anchor_set',
|
|
12
|
+
'Set a named coordinate anchor for relative drawing. E.g. anchor "head" at (20,5) then draw relative to it.',
|
|
13
|
+
{ name: z.string().describe('Anchor name'), x: z.number().int(), y: z.number().int() },
|
|
14
|
+
(p) => ({ action: 'setAnchor', ...p }));
|
|
15
|
+
|
|
16
|
+
registerTool(server, 'anchor_get',
|
|
17
|
+
'Get position of a named anchor',
|
|
18
|
+
{ name: z.string() },
|
|
19
|
+
(p) => ({ action: 'getAnchor', name: p.name }));
|
|
20
|
+
|
|
21
|
+
registerTool(server, 'anchor_list',
|
|
22
|
+
'List all named anchors',
|
|
23
|
+
{},
|
|
24
|
+
() => ({ action: 'listAnchors' }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { register };
|