@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.
Files changed (3) hide show
  1. package/index.js +563 -0
  2. package/package.json +26 -0
  3. 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(); });