@pixel-normal-edit/mcp 2.0.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.
- package/index.js +563 -0
- package/package.json +26 -0
- package/setup.js +153 -0
package/index.js
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Firebase Bridge v2
|
|
3
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Exposes Pixel Normal Edit's canvas API as structured MCP tools.
|
|
5
|
+
* Each tool maps 1:1 to a command-bus action with full schema validation.
|
|
6
|
+
*
|
|
7
|
+
* Third-party AI agents (Claude, GPT, Gemini via MCP) get:
|
|
8
|
+
* - Named, discoverable tools instead of one generic "execute" tool
|
|
9
|
+
* - Per-parameter validation (Zod schemas)
|
|
10
|
+
* - Rich descriptions so AI knows exactly what each param does
|
|
11
|
+
* - Visual feedback via querySnapshot / exportBase64
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
15
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
16
|
+
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
17
|
+
const { z } = require('zod');
|
|
18
|
+
const { initializeApp } = require('firebase/app');
|
|
19
|
+
const { getFirestore, doc, setDoc, onSnapshot } = require('firebase/firestore');
|
|
20
|
+
const dotenv = require('dotenv');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const http = require('http');
|
|
24
|
+
|
|
25
|
+
dotenv.config({ path: path.join(__dirname, '..', '.env') });
|
|
26
|
+
|
|
27
|
+
const db = getFirestore(initializeApp({
|
|
28
|
+
apiKey: process.env.VITE_FIREBASE_API_KEY || 'AIzaSyBSrvCt58Jhsh14wbC2bD2KLFUUVbAVim0',
|
|
29
|
+
authDomain: process.env.VITE_FIREBASE_AUTH_DOMAIN || 'pixel-normal-edit.firebaseapp.com',
|
|
30
|
+
projectId: process.env.VITE_FIREBASE_PROJECT_ID || 'pixel-normal-edit',
|
|
31
|
+
storageBucket: process.env.VITE_FIREBASE_STORAGE_BUCKET || 'pixel-normal-edit.firebasestorage.app',
|
|
32
|
+
messagingSenderId: process.env.VITE_FIREBASE_MESSAGING_SENDER_ID || '397075334229',
|
|
33
|
+
appId: process.env.VITE_FIREBASE_APP_ID || '1:397075334229:web:b02eede3fc7b41d02f80dc',
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const SESSION = process.argv[2] || process.env.MCP_SESSION || 'default-session';
|
|
37
|
+
const TIMEOUT = parseInt(process.env.MCP_TIMEOUT || '20000');
|
|
38
|
+
|
|
39
|
+
// ── Core: send any JSON command → wait for browser response ───────────────
|
|
40
|
+
async function sendCommand(payload) {
|
|
41
|
+
const id = crypto.randomUUID();
|
|
42
|
+
const ref = doc(db, 'mcp_sessions', SESSION, 'commands', id);
|
|
43
|
+
await setDoc(ref, { ...payload, status: 'pending', timestamp: Date.now() });
|
|
44
|
+
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
unsub();
|
|
48
|
+
resolve({ isError: true, content: [{ type: 'text', text: `⏱ Timeout (${TIMEOUT}ms). Is the browser tab open with session: ${SESSION}?` }] });
|
|
49
|
+
}, TIMEOUT);
|
|
50
|
+
|
|
51
|
+
const unsub = onSnapshot(ref, (snap) => {
|
|
52
|
+
const d = snap.data();
|
|
53
|
+
if (!d) return;
|
|
54
|
+
if (d.status === 'success') {
|
|
55
|
+
clearTimeout(timer); unsub();
|
|
56
|
+
const out = d.result !== undefined ? JSON.stringify(d.result, null, 2) : '✓ Done';
|
|
57
|
+
resolve({ content: [{ type: 'text', text: out }] });
|
|
58
|
+
} else if (d.status === 'error') {
|
|
59
|
+
clearTimeout(timer); unsub();
|
|
60
|
+
resolve({ isError: true, content: [{ type: 'text', text: `❌ ${d.error}` }] });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Tool factory: single-command tool ─────────────────────────────────────
|
|
67
|
+
function tool(server, name, desc, schema, mapToCmd) {
|
|
68
|
+
server.tool(name, desc, schema, async (params) => sendCommand(mapToCmd(params)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
72
|
+
const server = new McpServer({ name: 'PixelNormalEdit', version: '2.0.0' });
|
|
73
|
+
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
75
|
+
// GROUP 1: CANVAS & SETUP
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
77
|
+
tool(server, 'canvas_get_size',
|
|
78
|
+
'Get current canvas dimensions {width, height}',
|
|
79
|
+
{},
|
|
80
|
+
() => ({ action: 'getSize' }));
|
|
81
|
+
|
|
82
|
+
tool(server, 'canvas_resize',
|
|
83
|
+
'Resize canvas to new dimensions. mode=clear resets pixels, extend keeps them.',
|
|
84
|
+
{
|
|
85
|
+
width: z.number().int().min(1).max(1024).describe('New width in pixels'),
|
|
86
|
+
height: z.number().int().min(1).max(1024).describe('New height in pixels'),
|
|
87
|
+
mode: z.enum(['clear', 'extend', 'fit']).default('clear').describe('How to handle existing content'),
|
|
88
|
+
dx: z.number().int().default(0).describe('X offset for content when extending'),
|
|
89
|
+
dy: z.number().int().default(0).describe('Y offset for content when extending')
|
|
90
|
+
},
|
|
91
|
+
(p) => ({ action: 'resize', ...p }));
|
|
92
|
+
|
|
93
|
+
tool(server, 'canvas_clear',
|
|
94
|
+
'Erase all pixels on the current canvas frame',
|
|
95
|
+
{},
|
|
96
|
+
() => ({ action: 'clear' }));
|
|
97
|
+
|
|
98
|
+
tool(server, 'canvas_trim',
|
|
99
|
+
'Auto-trim transparent borders from canvas',
|
|
100
|
+
{},
|
|
101
|
+
() => ({ action: 'trim' }));
|
|
102
|
+
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
104
|
+
// GROUP 2: WORKSPACE / TABS
|
|
105
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
106
|
+
tool(server, 'workspace_list_tabs',
|
|
107
|
+
'List all open document tabs with their IDs and names',
|
|
108
|
+
{},
|
|
109
|
+
() => ({ action: 'listTabs' }));
|
|
110
|
+
|
|
111
|
+
tool(server, 'workspace_get_active_tab',
|
|
112
|
+
'Get the currently active tab ID',
|
|
113
|
+
{},
|
|
114
|
+
() => ({ action: 'getActiveTabId' }));
|
|
115
|
+
|
|
116
|
+
tool(server, 'workspace_create_tab',
|
|
117
|
+
'Create a new blank canvas tab',
|
|
118
|
+
{
|
|
119
|
+
name: z.string().optional().describe('Tab name'),
|
|
120
|
+
width: z.number().int().default(32).describe('Canvas width'),
|
|
121
|
+
height: z.number().int().default(32).describe('Canvas height')
|
|
122
|
+
},
|
|
123
|
+
(p) => ({ action: 'createTab', ...p }));
|
|
124
|
+
|
|
125
|
+
tool(server, 'workspace_switch_tab',
|
|
126
|
+
'Switch to a different tab by ID (get IDs from workspace_list_tabs)',
|
|
127
|
+
{ tabId: z.string().describe('Tab ID to switch to') },
|
|
128
|
+
(p) => ({ action: 'switchTab', tabId: p.tabId }));
|
|
129
|
+
|
|
130
|
+
tool(server, 'workspace_rename_tab',
|
|
131
|
+
'Rename a tab',
|
|
132
|
+
{ tabId: z.string(), name: z.string() },
|
|
133
|
+
(p) => ({ action: 'renameTab', ...p }));
|
|
134
|
+
|
|
135
|
+
tool(server, 'workspace_save',
|
|
136
|
+
'Quick-save the current workspace',
|
|
137
|
+
{},
|
|
138
|
+
() => ({ action: 'quickSave' }));
|
|
139
|
+
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
141
|
+
// GROUP 3: ANIMATION FRAMES
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
143
|
+
tool(server, 'animation_enable',
|
|
144
|
+
'Enable or disable animation mode. When enabled, the canvas has multiple frames.',
|
|
145
|
+
{ enabled: z.boolean().describe('true to enable, false to disable') },
|
|
146
|
+
(p) => ({ action: 'setAnimationMode', enabled: p.enabled }));
|
|
147
|
+
|
|
148
|
+
tool(server, 'animation_add_frame',
|
|
149
|
+
'Append a new blank frame to the animation',
|
|
150
|
+
{},
|
|
151
|
+
() => ({ action: 'addFrame' }));
|
|
152
|
+
|
|
153
|
+
tool(server, 'animation_go_to_frame',
|
|
154
|
+
'Switch to editing a specific frame by index (0-based)',
|
|
155
|
+
{ index: z.number().int().min(0).describe('Frame index') },
|
|
156
|
+
(p) => ({ action: 'goToFrame', index: p.index }));
|
|
157
|
+
|
|
158
|
+
tool(server, 'animation_ensure_frame',
|
|
159
|
+
'Make sure the animation has at least (index+1) frames, then switch to that frame',
|
|
160
|
+
{ index: z.number().int().min(0).describe('Target frame index') },
|
|
161
|
+
(p) => ({ action: 'ensureFrame', index: p.index }));
|
|
162
|
+
|
|
163
|
+
tool(server, 'animation_get_info',
|
|
164
|
+
'Get current frame count and active frame index',
|
|
165
|
+
{},
|
|
166
|
+
async () => {
|
|
167
|
+
const [count, idx] = await Promise.all([
|
|
168
|
+
sendCommand({ action: 'getFrameCount' }),
|
|
169
|
+
sendCommand({ action: 'getActiveFrameIndex' }),
|
|
170
|
+
]);
|
|
171
|
+
return { content: [{ type: 'text', text: JSON.stringify({ frameCount: count, activeIndex: idx }) }] };
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
tool(server, 'animation_remove_frame',
|
|
175
|
+
'Remove frame at the given index',
|
|
176
|
+
{ index: z.number().int().min(0) },
|
|
177
|
+
(p) => ({ action: 'removeFrame', index: p.index }));
|
|
178
|
+
|
|
179
|
+
tool(server, 'animation_reorder_frame',
|
|
180
|
+
'Move a frame from one position to another',
|
|
181
|
+
{ from: z.number().int().min(0), to: z.number().int().min(0) },
|
|
182
|
+
(p) => ({ action: 'reorderFrame', from: p.from, to: p.to }));
|
|
183
|
+
|
|
184
|
+
tool(server, 'animation_compare_frames',
|
|
185
|
+
'Get list of pixel differences between two frames',
|
|
186
|
+
{ frameIndex1: z.number().int().min(0), frameIndex2: z.number().int().min(0) },
|
|
187
|
+
(p) => ({ action: 'getFrameDifferences', ...p }));
|
|
188
|
+
|
|
189
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
190
|
+
// GROUP 4: DRAWING PRIMITIVES
|
|
191
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
192
|
+
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/).describe('Hex color e.g. #ff0000');
|
|
193
|
+
|
|
194
|
+
tool(server, 'draw_pixel',
|
|
195
|
+
'Set a single pixel to a color',
|
|
196
|
+
{
|
|
197
|
+
x: z.number().int().describe('X coordinate'),
|
|
198
|
+
y: z.number().int().describe('Y coordinate'),
|
|
199
|
+
color: hexColor
|
|
200
|
+
},
|
|
201
|
+
(p) => ({ action: 'drawPixel', ...p }));
|
|
202
|
+
|
|
203
|
+
tool(server, 'draw_erase',
|
|
204
|
+
'Erase a single pixel (make transparent)',
|
|
205
|
+
{ x: z.number().int(), y: z.number().int() },
|
|
206
|
+
(p) => ({ action: 'erasePixel', ...p }));
|
|
207
|
+
|
|
208
|
+
tool(server, 'draw_line',
|
|
209
|
+
'Draw a straight line using Bresenham algorithm',
|
|
210
|
+
{
|
|
211
|
+
x0: z.number().int(), y0: z.number().int(),
|
|
212
|
+
x1: z.number().int(), y1: z.number().int(),
|
|
213
|
+
color: hexColor
|
|
214
|
+
},
|
|
215
|
+
(p) => ({ action: 'drawLine', ...p }));
|
|
216
|
+
|
|
217
|
+
tool(server, 'draw_rect',
|
|
218
|
+
'Draw a rectangle (outline or filled)',
|
|
219
|
+
{
|
|
220
|
+
x: z.number().int().describe('Top-left X'),
|
|
221
|
+
y: z.number().int().describe('Top-left Y'),
|
|
222
|
+
w: z.number().int().min(1).describe('Width'),
|
|
223
|
+
h: z.number().int().min(1).describe('Height'),
|
|
224
|
+
color: hexColor,
|
|
225
|
+
filled: z.boolean().default(false).describe('Fill interior?')
|
|
226
|
+
},
|
|
227
|
+
(p) => ({ action: 'drawRect', ...p }));
|
|
228
|
+
|
|
229
|
+
tool(server, 'draw_circle',
|
|
230
|
+
'Draw a circle (outline or filled)',
|
|
231
|
+
{
|
|
232
|
+
cx: z.number().int().describe('Center X'),
|
|
233
|
+
cy: z.number().int().describe('Center Y'),
|
|
234
|
+
r: z.number().int().min(1).describe('Radius'),
|
|
235
|
+
color: hexColor,
|
|
236
|
+
filled: z.boolean().default(false)
|
|
237
|
+
},
|
|
238
|
+
(p) => ({ action: 'drawCircle', ...p }));
|
|
239
|
+
|
|
240
|
+
tool(server, 'draw_ellipse',
|
|
241
|
+
'Draw an ellipse (outline or filled)',
|
|
242
|
+
{
|
|
243
|
+
cx: z.number().int(), cy: z.number().int(),
|
|
244
|
+
rx: z.number().int().min(1).describe('Horizontal radius'),
|
|
245
|
+
ry: z.number().int().min(1).describe('Vertical radius'),
|
|
246
|
+
color: hexColor,
|
|
247
|
+
filled: z.boolean().default(false)
|
|
248
|
+
},
|
|
249
|
+
(p) => ({ action: 'drawEllipse', ...p }));
|
|
250
|
+
|
|
251
|
+
tool(server, 'draw_polygon',
|
|
252
|
+
'Draw a polygon from a list of points (outline or filled)',
|
|
253
|
+
{
|
|
254
|
+
points: z.array(z.object({ x: z.number().int(), y: z.number().int() })).min(3).describe('Array of {x,y} vertices'),
|
|
255
|
+
color: hexColor,
|
|
256
|
+
filled: z.boolean().default(false)
|
|
257
|
+
},
|
|
258
|
+
(p) => ({ action: 'drawPolygon', ...p }));
|
|
259
|
+
|
|
260
|
+
tool(server, 'draw_fill',
|
|
261
|
+
'Flood-fill starting from (x,y) with a color',
|
|
262
|
+
{ x: z.number().int(), y: z.number().int(), color: hexColor },
|
|
263
|
+
(p) => ({ action: 'fill', ...p }));
|
|
264
|
+
|
|
265
|
+
tool(server, 'draw_gradient_rect',
|
|
266
|
+
'Draw a rectangle filled with a smooth gradient',
|
|
267
|
+
{
|
|
268
|
+
x: z.number().int(), y: z.number().int(),
|
|
269
|
+
w: z.number().int().min(1), h: z.number().int().min(1),
|
|
270
|
+
colorFrom: hexColor.describe('Start color'),
|
|
271
|
+
colorTo: hexColor.describe('End color'),
|
|
272
|
+
direction: z.enum(['h', 'v']).default('h').describe('h=horizontal, v=vertical')
|
|
273
|
+
},
|
|
274
|
+
(p) => ({ action: 'drawGradientRect', ...p }));
|
|
275
|
+
|
|
276
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
277
|
+
// GROUP 5: BULK OPERATIONS
|
|
278
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
279
|
+
tool(server, 'bulk_replace_color',
|
|
280
|
+
'Replace ALL pixels of one color with another across the entire canvas',
|
|
281
|
+
{
|
|
282
|
+
from: hexColor.describe('Color to replace'),
|
|
283
|
+
to: hexColor.describe('New color')
|
|
284
|
+
},
|
|
285
|
+
(p) => ({ action: 'replaceColor', from: p.from, to: p.to }));
|
|
286
|
+
|
|
287
|
+
tool(server, 'bulk_flood_fill_all',
|
|
288
|
+
'Flood-fill every disconnected region of a given color',
|
|
289
|
+
{
|
|
290
|
+
color: hexColor.describe('Color to replace'),
|
|
291
|
+
to: hexColor.describe('Replacement color')
|
|
292
|
+
},
|
|
293
|
+
(p) => ({ action: 'floodFillAll', color: p.color, to: p.to }));
|
|
294
|
+
|
|
295
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
296
|
+
// GROUP 6: SPRITE / STAMP SYSTEM
|
|
297
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
298
|
+
tool(server, 'sprite_draw',
|
|
299
|
+
`Draw a sprite using ASCII art + color palette. Each character in the data array maps to a color.
|
|
300
|
+
EXAMPLE:
|
|
301
|
+
palette: { "H": "#ffcc99", "B": "#1565c0", ".": null }
|
|
302
|
+
data: [ "..H..", ".BBB.", "..H.." ]
|
|
303
|
+
This is the most efficient way to draw complex shapes — replaces dozens of drawPixel calls.`,
|
|
304
|
+
{
|
|
305
|
+
x: z.number().int().default(0).describe('Top-left X offset'),
|
|
306
|
+
y: z.number().int().default(0).describe('Top-left Y offset'),
|
|
307
|
+
palette: z.record(z.string().length(1), z.string().nullable()).describe('Char→hex map. null = transparent/skip'),
|
|
308
|
+
data: z.array(z.string()).min(1).describe('Rows of ASCII art characters')
|
|
309
|
+
},
|
|
310
|
+
(p) => ({ action: 'drawSprite', ...p }));
|
|
311
|
+
|
|
312
|
+
tool(server, 'sprite_save_stamp',
|
|
313
|
+
'Save a named sprite for reuse across frames. Use sprite_use_stamp to place it.',
|
|
314
|
+
{
|
|
315
|
+
name: z.string().describe('Unique stamp name'),
|
|
316
|
+
palette: z.record(z.string().length(1), z.string().nullable()),
|
|
317
|
+
data: z.array(z.string()).min(1)
|
|
318
|
+
},
|
|
319
|
+
(p) => ({ action: 'saveStamp', ...p }));
|
|
320
|
+
|
|
321
|
+
tool(server, 'sprite_use_stamp',
|
|
322
|
+
'Place a previously saved stamp at a position. Optionally override palette colors.',
|
|
323
|
+
{
|
|
324
|
+
name: z.string().describe('Stamp name (from sprite_save_stamp)'),
|
|
325
|
+
x: z.number().int().default(0),
|
|
326
|
+
y: z.number().int().default(0),
|
|
327
|
+
palette: z.record(z.string().length(1), z.string().nullable()).optional().describe('Override specific palette colors')
|
|
328
|
+
},
|
|
329
|
+
(p) => ({ action: 'useStamp', ...p }));
|
|
330
|
+
|
|
331
|
+
tool(server, 'sprite_list_stamps',
|
|
332
|
+
'List all saved stamp names',
|
|
333
|
+
{},
|
|
334
|
+
() => ({ action: 'listStamps' }));
|
|
335
|
+
|
|
336
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
337
|
+
// GROUP 7: REGION CLIPBOARD
|
|
338
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
339
|
+
tool(server, 'region_copy',
|
|
340
|
+
'Copy a rectangular region of pixels to an internal clipboard',
|
|
341
|
+
{
|
|
342
|
+
x: z.number().int().default(0), y: z.number().int().default(0),
|
|
343
|
+
w: z.number().int().min(1).describe('Width of region'),
|
|
344
|
+
h: z.number().int().min(1).describe('Height of region')
|
|
345
|
+
},
|
|
346
|
+
(p) => ({ action: 'copyRegion', ...p }));
|
|
347
|
+
|
|
348
|
+
tool(server, 'region_paste',
|
|
349
|
+
'Paste the clipboard region at (x,y). Defaults to original position.',
|
|
350
|
+
{
|
|
351
|
+
x: z.number().int().optional().describe('X destination (default: original X)'),
|
|
352
|
+
y: z.number().int().optional().describe('Y destination (default: original Y)')
|
|
353
|
+
},
|
|
354
|
+
(p) => ({ action: 'pasteRegion', ...p }));
|
|
355
|
+
|
|
356
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
357
|
+
// GROUP 8: FILTERS
|
|
358
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
359
|
+
tool(server, 'filter_apply',
|
|
360
|
+
`Apply a visual filter to the canvas (or a region).
|
|
361
|
+
Types: brightness (value: -255 to 255), invert, grayscale, hue-rotate (value: degrees 0-360)`,
|
|
362
|
+
{
|
|
363
|
+
type: z.enum(['brightness', 'invert', 'grayscale', 'hue-rotate']),
|
|
364
|
+
value: z.number().optional().describe('Parameter: brightness=-255..255, hue-rotate=0..360'),
|
|
365
|
+
x: z.number().int().optional(), y: z.number().int().optional(),
|
|
366
|
+
w: z.number().int().optional(), h: z.number().int().optional()
|
|
367
|
+
},
|
|
368
|
+
(p) => ({ action: 'applyFilter', ...p }));
|
|
369
|
+
|
|
370
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
371
|
+
// GROUP 9: ANCHORS
|
|
372
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
373
|
+
tool(server, 'anchor_set',
|
|
374
|
+
'Set a named coordinate anchor for relative drawing. E.g. anchor "head" at (20,5) then draw relative to it.',
|
|
375
|
+
{
|
|
376
|
+
name: z.string().describe('Anchor name'),
|
|
377
|
+
x: z.number().int(), y: z.number().int()
|
|
378
|
+
},
|
|
379
|
+
(p) => ({ action: 'setAnchor', ...p }));
|
|
380
|
+
|
|
381
|
+
tool(server, 'anchor_get',
|
|
382
|
+
'Get position of a named anchor',
|
|
383
|
+
{ name: z.string() },
|
|
384
|
+
(p) => ({ action: 'getAnchor', name: p.name }));
|
|
385
|
+
|
|
386
|
+
tool(server, 'anchor_list',
|
|
387
|
+
'List all named anchors',
|
|
388
|
+
{},
|
|
389
|
+
() => ({ action: 'listAnchors' }));
|
|
390
|
+
|
|
391
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
392
|
+
// GROUP 10: QUERY & VISUAL FEEDBACK ← Critical for AI agents
|
|
393
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
394
|
+
tool(server, 'query_snapshot',
|
|
395
|
+
`Get an ASCII art representation of the current canvas frame. Use this to "see" what you've drawn before continuing.
|
|
396
|
+
Returns: { ascii, legend, width, height } where each symbol in ascii maps to a hex color via legend.
|
|
397
|
+
Increase scale to get a smaller grid (scale=2 means 1 char = 2×2 pixels).`,
|
|
398
|
+
{
|
|
399
|
+
scale: z.number().int().min(1).max(8).default(1).describe('Downscale factor (1=full resolution)'),
|
|
400
|
+
maxColors: z.number().int().min(2).max(32).default(12).describe('Max distinct colors in legend')
|
|
401
|
+
},
|
|
402
|
+
(p) => ({ action: 'querySnapshot', ...p }));
|
|
403
|
+
|
|
404
|
+
tool(server, 'query_bounding_box',
|
|
405
|
+
'Get the bounding box of all non-transparent pixels {minX, minY, maxX, maxY, width, height}',
|
|
406
|
+
{},
|
|
407
|
+
() => ({ action: 'query', type: 'getBoundingBox' }));
|
|
408
|
+
|
|
409
|
+
tool(server, 'query_palette',
|
|
410
|
+
'Get list of all distinct colors used on the canvas',
|
|
411
|
+
{},
|
|
412
|
+
() => ({ action: 'query', type: 'getPalette' }));
|
|
413
|
+
|
|
414
|
+
tool(server, 'query_pixel',
|
|
415
|
+
'Get the color of a specific pixel. Returns hex string or null if transparent.',
|
|
416
|
+
{ x: z.number().int(), y: z.number().int() },
|
|
417
|
+
(p) => ({ action: 'getPixel', x: p.x, y: p.y }));
|
|
418
|
+
|
|
419
|
+
tool(server, 'query_export_image',
|
|
420
|
+
`Export the current frame as a base64 PNG data URL.
|
|
421
|
+
Use this to visually verify your work — paste the dataUrl into an image viewer.
|
|
422
|
+
The dataUrl starts with "data:image/png;base64,..."`,
|
|
423
|
+
{ format: z.enum(['png', 'webp', 'jpeg']).default('png') },
|
|
424
|
+
(p) => ({ action: 'exportBase64', format: p.format }));
|
|
425
|
+
|
|
426
|
+
tool(server, 'query_document_state',
|
|
427
|
+
'Get full document state: tab name, canvas size, animation info, undo availability',
|
|
428
|
+
{},
|
|
429
|
+
() => ({ action: 'query', type: 'getDocumentState' }));
|
|
430
|
+
|
|
431
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
432
|
+
// GROUP 11: HISTORY
|
|
433
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
434
|
+
tool(server, 'history_undo',
|
|
435
|
+
'Undo the last drawing action',
|
|
436
|
+
{},
|
|
437
|
+
() => ({ action: 'undo' }));
|
|
438
|
+
|
|
439
|
+
tool(server, 'history_redo',
|
|
440
|
+
'Redo the last undone action',
|
|
441
|
+
{},
|
|
442
|
+
() => ({ action: 'redo' }));
|
|
443
|
+
|
|
444
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
445
|
+
// GROUP 12: MODES
|
|
446
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
447
|
+
tool(server, 'mode_set_mirror',
|
|
448
|
+
'Enable/disable mirror drawing mode (symmetric left-right)',
|
|
449
|
+
{ enabled: z.boolean() },
|
|
450
|
+
(p) => ({ action: 'setMirror', enabled: p.enabled }));
|
|
451
|
+
|
|
452
|
+
tool(server, 'mode_set_onion_skin',
|
|
453
|
+
'Enable/disable onion skin (shows previous frame as ghost)',
|
|
454
|
+
{ enabled: z.boolean() },
|
|
455
|
+
(p) => ({ action: 'setOnionSkin', enabled: p.enabled }));
|
|
456
|
+
|
|
457
|
+
tool(server, 'mode_set_grid',
|
|
458
|
+
'Show/hide the pixel grid overlay',
|
|
459
|
+
{ enabled: z.boolean() },
|
|
460
|
+
(p) => ({ action: 'setGrid', enabled: p.enabled }));
|
|
461
|
+
|
|
462
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
463
|
+
// GROUP 13: COLOR
|
|
464
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
465
|
+
tool(server, 'color_set',
|
|
466
|
+
'Set the primary and/or secondary color',
|
|
467
|
+
{ primary: hexColor.optional(), secondary: hexColor.optional() },
|
|
468
|
+
(p) => ({ action: 'setColor', ...p }));
|
|
469
|
+
|
|
470
|
+
tool(server, 'color_get',
|
|
471
|
+
'Get current primary and secondary colors',
|
|
472
|
+
{},
|
|
473
|
+
() => ({ action: 'getColor' }));
|
|
474
|
+
|
|
475
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
476
|
+
// HEALTH CHECK
|
|
477
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
478
|
+
tool(server, 'ping',
|
|
479
|
+
'Check if the browser editor is connected and responding',
|
|
480
|
+
{},
|
|
481
|
+
() => ({ action: 'ping' }));
|
|
482
|
+
|
|
483
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
484
|
+
// MODE: stdio (default) OR http (when --http flag or HTTP_PORT env is set)
|
|
485
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
486
|
+
const HTTP_PORT = process.env.HTTP_PORT || (process.argv.includes('--http') ? 3456 : null);
|
|
487
|
+
|
|
488
|
+
async function startStdio() {
|
|
489
|
+
const transport = new StdioServerTransport();
|
|
490
|
+
await server.connect(transport);
|
|
491
|
+
console.error('✅ Pixel Normal Edit MCP Bridge v2 (stdio mode)');
|
|
492
|
+
console.error(` Session : ${SESSION}`);
|
|
493
|
+
console.error(` Editor : http://localhost:5173?mcp_session=${SESSION}`);
|
|
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(`\n🚀 Pixel 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
|
+
if (HTTP_PORT) {
|
|
551
|
+
// Rebuild server factory for HTTP (stateless per-request instances)
|
|
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);
|
|
561
|
+
} else {
|
|
562
|
+
startStdio().catch(console.error);
|
|
563
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pixel-normal-edit/mcp",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "MCP server for Pixel Normal Edit canvas - enables AI agents to draw pixel art",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pixel-normal-edit-mcp": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js",
|
|
11
|
+
"mcp": "node index.js",
|
|
12
|
+
"mcp:http": "node index.js --http",
|
|
13
|
+
"mcp:setup": "node setup.js"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["mcp", "pixel-art", "ai", "canvas"],
|
|
16
|
+
"author": "",
|
|
17
|
+
"license": "ISC",
|
|
18
|
+
"type": "commonjs",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
21
|
+
"dotenv": "^17.4.2",
|
|
22
|
+
"firebase": "^12.16.0",
|
|
23
|
+
"zod": "^4.4.3"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
package/setup.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* setup.js — Interactive setup wizard for Pixel Normal Edit MCP
|
|
4
|
+
* Run: node mcp-firebase-bridge/setup.js
|
|
5
|
+
*
|
|
6
|
+
* Tự động:
|
|
7
|
+
* 1. Đọc Firebase config từ .env có sẵn (nếu tồn tại)
|
|
8
|
+
* 2. Hỏi session ID
|
|
9
|
+
* 3. Chọn chế độ: stdio hoặc HTTP
|
|
10
|
+
* 4. In ra mcp_config.json đúng format để user copy
|
|
11
|
+
* 5. (Tùy chọn) Ghi thẳng vào .gemini/config/mcp_config.json
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const { createInterface } = require('readline');
|
|
18
|
+
|
|
19
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
20
|
+
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
21
|
+
|
|
22
|
+
const ROOT = path.join(__dirname, '..');
|
|
23
|
+
const ENV_PATH = path.join(ROOT, '.env');
|
|
24
|
+
const GEMINI_MCP = path.join(os.homedir(), '.gemini', 'config', 'mcp_config.json');
|
|
25
|
+
|
|
26
|
+
function parseEnvFile(file) {
|
|
27
|
+
try {
|
|
28
|
+
return Object.fromEntries(
|
|
29
|
+
fs.readFileSync(file,'utf-8').split('\n')
|
|
30
|
+
.filter(l => l.includes('=') && !l.startsWith('#'))
|
|
31
|
+
.map(l => { const [k,...v]=l.split('='); return [k.trim(),v.join('=').trim()]; })
|
|
32
|
+
);
|
|
33
|
+
} catch { return {}; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function main() {
|
|
37
|
+
console.log('\n╔══════════════════════════════════════════════════╗');
|
|
38
|
+
console.log('║ Pixel Normal Edit — MCP Setup Wizard ║');
|
|
39
|
+
console.log('╚══════════════════════════════════════════════════╝\n');
|
|
40
|
+
|
|
41
|
+
// ── Step 1: Check .env ──────────────────────────────────────────────
|
|
42
|
+
const env = parseEnvFile(ENV_PATH);
|
|
43
|
+
const hasFirebase = !!(env.VITE_FIREBASE_API_KEY && env.VITE_FIREBASE_PROJECT_ID);
|
|
44
|
+
|
|
45
|
+
if (hasFirebase) {
|
|
46
|
+
console.log('✅ Firebase config found in .env\n');
|
|
47
|
+
} else {
|
|
48
|
+
console.log('⚠️ No .env file found. Firebase config is required.');
|
|
49
|
+
console.log(' Create a .env file with your Firebase credentials first.\n');
|
|
50
|
+
console.log(' Required keys:');
|
|
51
|
+
console.log(' VITE_FIREBASE_API_KEY=...');
|
|
52
|
+
console.log(' VITE_FIREBASE_AUTH_DOMAIN=...');
|
|
53
|
+
console.log(' VITE_FIREBASE_PROJECT_ID=...');
|
|
54
|
+
console.log(' VITE_FIREBASE_STORAGE_BUCKET=...');
|
|
55
|
+
console.log(' VITE_FIREBASE_MESSAGING_SENDER_ID=...');
|
|
56
|
+
console.log(' VITE_FIREBASE_APP_ID=...\n');
|
|
57
|
+
rl.close(); return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Step 2: Session ID ──────────────────────────────────────────────
|
|
61
|
+
const sessionId = (await ask('📌 Session ID (press Enter for "my-session"): ')).trim() || 'my-session';
|
|
62
|
+
|
|
63
|
+
// ── Step 3: Mode ─────────────────────────────────────────────────────
|
|
64
|
+
console.log('\n📡 Connection mode:');
|
|
65
|
+
console.log(' 1. stdio — Direct process (simplest, for Claude Desktop / Antigravity)');
|
|
66
|
+
console.log(' 2. HTTP — Local server + npx mcp-remote (for any AI client)\n');
|
|
67
|
+
const modeChoice = (await ask('Choose [1/2] (Enter = 1): ')).trim() || '1';
|
|
68
|
+
const useHttp = modeChoice === '2';
|
|
69
|
+
const httpPort = useHttp
|
|
70
|
+
? ((await ask('HTTP Port (Enter = 3456): ')).trim() || '3456')
|
|
71
|
+
: null;
|
|
72
|
+
|
|
73
|
+
// ── Step 4: Build config ─────────────────────────────────────────────
|
|
74
|
+
const bridgePath = path.join(__dirname, 'index.js').replace(/\\/g, '\\\\');
|
|
75
|
+
|
|
76
|
+
let mcpConfig;
|
|
77
|
+
if (useHttp) {
|
|
78
|
+
mcpConfig = {
|
|
79
|
+
mcpServers: {
|
|
80
|
+
'pixel-normal-edit': {
|
|
81
|
+
command: 'npx',
|
|
82
|
+
args: ['-y', 'mcp-remote', `http://localhost:${httpPort}/mcp`]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
} else {
|
|
87
|
+
mcpConfig = {
|
|
88
|
+
mcpServers: {
|
|
89
|
+
'pixel-normal-edit': {
|
|
90
|
+
command: 'node',
|
|
91
|
+
args: [bridgePath, sessionId],
|
|
92
|
+
env: {
|
|
93
|
+
MCP_SESSION: sessionId,
|
|
94
|
+
MCP_TIMEOUT: '20000'
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const configStr = JSON.stringify(mcpConfig, null, 2);
|
|
102
|
+
|
|
103
|
+
// ── Step 5: Output ────────────────────────────────────────────────────
|
|
104
|
+
console.log('\n' + '─'.repeat(52));
|
|
105
|
+
console.log('✅ Your mcp_config.json:\n');
|
|
106
|
+
console.log(configStr);
|
|
107
|
+
console.log('─'.repeat(52));
|
|
108
|
+
|
|
109
|
+
// ── Step 6: Write .agents/mcp_config.json ────────────────────────────
|
|
110
|
+
const agentsDir = path.join(ROOT, '.agents');
|
|
111
|
+
const agentsFile = path.join(agentsDir, 'mcp_config.json');
|
|
112
|
+
if (!fs.existsSync(agentsDir)) fs.mkdirSync(agentsDir, { recursive: true });
|
|
113
|
+
fs.writeFileSync(agentsFile, configStr, 'utf-8');
|
|
114
|
+
console.log(`\n✅ Written to: ${agentsFile}`);
|
|
115
|
+
|
|
116
|
+
// ── Step 7: Offer to write global Gemini config ───────────────────────
|
|
117
|
+
const writeGlobal = (await ask(`\nAlso write to global Antigravity config? [y/N]: `)).trim().toLowerCase();
|
|
118
|
+
if (writeGlobal === 'y') {
|
|
119
|
+
try {
|
|
120
|
+
// Merge with existing
|
|
121
|
+
let existing = {};
|
|
122
|
+
if (fs.existsSync(GEMINI_MCP)) {
|
|
123
|
+
try { existing = JSON.parse(fs.readFileSync(GEMINI_MCP,'utf-8')); } catch {}
|
|
124
|
+
}
|
|
125
|
+
const merged = { mcpServers: { ...existing.mcpServers, ...mcpConfig.mcpServers } };
|
|
126
|
+
fs.writeFileSync(GEMINI_MCP, JSON.stringify(merged,null,2), 'utf-8');
|
|
127
|
+
console.log(`✅ Written to: ${GEMINI_MCP}`);
|
|
128
|
+
} catch(e) {
|
|
129
|
+
console.log(`⚠️ Could not write to ${GEMINI_MCP}: ${e.message}`);
|
|
130
|
+
console.log(' Manually paste the config above instead.');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Step 8: Start instructions ────────────────────────────────────────
|
|
135
|
+
console.log('\n' + '═'.repeat(52));
|
|
136
|
+
console.log('📋 NEXT STEPS:\n');
|
|
137
|
+
|
|
138
|
+
if (useHttp) {
|
|
139
|
+
console.log(` 1. Start HTTP server:`);
|
|
140
|
+
console.log(` node mcp-firebase-bridge/index.js --http\n`);
|
|
141
|
+
console.log(` 2. Open editor:`);
|
|
142
|
+
console.log(` http://localhost:5173?mcp_session=${sessionId}\n`);
|
|
143
|
+
console.log(` 3. Restart Antigravity — it will connect automatically`);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(` 1. Open editor:`);
|
|
146
|
+
console.log(` http://localhost:5173?mcp_session=${sessionId}\n`);
|
|
147
|
+
console.log(` 2. Restart Antigravity — it will connect automatically`);
|
|
148
|
+
}
|
|
149
|
+
console.log('\n' + '═'.repeat(52) + '\n');
|
|
150
|
+
rl.close();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
main().catch(e => { console.error('Error:', e.message); rl.close(); });
|