@pixel-normal-edit/mcp 2.0.3 → 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/index.js CHANGED
@@ -1,563 +1,49 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * MCP Firebase Bridge v2
4
- * ─────────────────────────────────────────────────────────────────────────
5
- * Exposes Pixel Normal Edit's canvas API as structured MCP tools.
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
- * Third-party AI agents (Claude, GPT, Gemini via MCP) get:
9
- * - Named, discoverable tools instead of one generic "execute" tool
10
- * - Per-parameter validation (Zod schemas)
11
- * - Rich descriptions so AI knows exactly what each param does
12
- * - Visual feedback via querySnapshot / exportBase64
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 { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
16
- const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
17
- const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
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: process.env.VITE_FIREBASE_API_KEY,
30
- authDomain: process.env.VITE_FIREBASE_AUTH_DOMAIN,
31
- projectId: process.env.VITE_FIREBASE_PROJECT_ID,
32
- storageBucket: process.env.VITE_FIREBASE_STORAGE_BUCKET,
33
- messagingSenderId: process.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
34
- appId: process.env.VITE_FIREBASE_APP_ID,
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
- tool(server, 'draw_ellipse',
242
- 'Draw an ellipse (outline or filled)',
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
- tool(server, 'draw_polygon',
253
- 'Draw a polygon from a list of points (outline or filled)',
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
- tool(server, 'draw_fill',
262
- 'Flood-fill starting from (x,y) with a color',
263
- { x: z.number().int(), y: z.number().int(), color: hexColor },
264
- (p) => ({ action: 'fill', ...p }));
38
+ // ── Register Domain Modules ───────────────────────────────────────────────
39
+ registerArtTree(server);
40
+ // Future domains: registerPixelArt(server), registerCharacterDrawing(server), etc.
265
41
 
266
- tool(server, 'draw_gradient_rect',
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
- // 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);
46
+ require('./transport/http').start(parseInt(HTTP_PORT)).catch(console.error);
561
47
  } else {
562
- startStdio().catch(console.error);
563
- }
48
+ require('./transport/stdio').start(server).catch(console.error);
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixel-normal-edit/mcp",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "MCP server for Pixel Normal Edit canvas - enables AI agents to draw pixel art",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -28,4 +28,4 @@
28
28
  "jimp": "^1.6.1",
29
29
  "zod": "^4.4.3"
30
30
  }
31
- }
31
+ }
@@ -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 };