picturereader-zcode 1.0.3

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.
@@ -0,0 +1,593 @@
1
+ /**
2
+ * picturereader MCP server — exposes the image-reading tools to ZCode as a
3
+ * Model Context Protocol stdio server.
4
+ *
5
+ * Three tools, identical in behavior to the original DSH plugin:
6
+ * - `image_scan` coarse pixel grid + hue/color/structure analysis
7
+ * - `image_ocr` text recognition (Windows OCR default, PaddleOCR optional)
8
+ * - `image_sample` exact-pixel texture sampling for material judgment
9
+ *
10
+ * The entire business logic lives in `src/core.js`, loaded dynamically with a
11
+ * cache-busting query keyed on the file's mtime (see `importCore`), so editing
12
+ * `core.js` takes effect on the NEXT tool call without restarting the server.
13
+ *
14
+ * The transport is minimal newline-delimited JSON-RPC 2.0 over stdio, as
15
+ * specified by MCP: every line on stdin is one JSON-RPC message; responses are
16
+ * written as single JSON lines on stdout. Diagnostics go to stderr only, so
17
+ * they never corrupt the protocol stream. No MCP SDK dependency is needed.
18
+ * @module picturereader/mcp/server
19
+ */
20
+
21
+ import { extname, isAbsolute, resolve as resolvePath } from 'node:path';
22
+ import { stat, readFile } from 'node:fs/promises';
23
+ import { createInterface } from 'node:readline';
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ /** The MCP protocol version this server speaks. */
27
+ export const PROTOCOL_VERSION = '2025-06-18';
28
+
29
+ /** Hard cap on file bytes we are willing to read for a scan. */
30
+ export const BYTE_CAP = 50 * 1024 * 1024;
31
+ /** Hard cap on decoded pixel count (pure-JS decoders are slow on huge images). */
32
+ export const MAX_PIXELS = 24_000_000;
33
+
34
+ const CORE_URL = new URL('../src/core.js', import.meta.url).href;
35
+
36
+ let coreCache = { url: null, mtime: -1, module: null };
37
+
38
+ /**
39
+ * Load the latest `core.js`, refreshing the module whenever the file changes.
40
+ * Exported for tests; the optional `target` overrides the module URL.
41
+ * @param target - module URL to load (defaults to this package's core.js).
42
+ * @returns the core module namespace.
43
+ */
44
+ export async function importCore(target = CORE_URL) {
45
+ const url = new URL(target);
46
+ const info = await stat(url);
47
+ if (coreCache.module !== null && coreCache.url === target && info.mtimeMs === coreCache.mtime) {
48
+ return coreCache.module;
49
+ }
50
+ const module = await import(`${url.href}?t=${info.mtimeMs}`);
51
+ coreCache = { url: target, mtime: info.mtimeMs, module };
52
+ return module;
53
+ }
54
+
55
+ /**
56
+ * Resolve a model-supplied path to an absolute path. Absolute paths are used
57
+ * as-is; relative paths resolve against the workspace root (the
58
+ * `PICTUREREADER_CWD` env var, set by the plugin config, or the server's
59
+ * process cwd when launched manually).
60
+ * @param filePath - the raw path argument.
61
+ * @returns the absolute path.
62
+ */
63
+ export function resolveImagePath(filePath) {
64
+ const base = process.env.PICTUREREADER_CWD || process.cwd();
65
+ return isAbsolute(filePath) ? filePath : resolvePath(base, filePath);
66
+ }
67
+
68
+ /**
69
+ * Read and validate an image file from disk.
70
+ * @param filePath - absolute path.
71
+ * @param toolName - used in error messages ('image_scan' etc.).
72
+ * @returns the file bytes.
73
+ */
74
+ export async function readImageFile(filePath, toolName = 'image_scan') {
75
+ let info;
76
+ try {
77
+ info = await stat(filePath);
78
+ } catch {
79
+ throw new Error(`${toolName}: cannot read "${filePath}": file not found`);
80
+ }
81
+ if (!info.isFile()) {
82
+ throw new Error(`${toolName}: cannot read "${filePath}": not a regular file`);
83
+ }
84
+ const data = await readFile(filePath);
85
+ if (data.byteLength > BYTE_CAP) {
86
+ throw new Error(`${toolName}: file exceeds the ${BYTE_CAP}-byte read limit`);
87
+ }
88
+ return data;
89
+ }
90
+
91
+ /** Coerce the size argument into a bounded integer. */
92
+ function parseSize(raw) {
93
+ const size = Number(raw ?? 32);
94
+ if (!Number.isInteger(size) || size < 8 || size > 64) {
95
+ throw new Error('image_scan: size must be an integer between 8 and 64');
96
+ }
97
+ return size;
98
+ }
99
+
100
+ function parseMode(raw) {
101
+ const mode = String(raw ?? 'auto');
102
+ if (mode !== 'auto' && mode !== 'ascii' && mode !== 'color') {
103
+ throw new Error("image_scan: mode must be one of 'auto', 'ascii', 'color'");
104
+ }
105
+ return mode;
106
+ }
107
+
108
+ /**
109
+ * Validate a file_path argument and load the latest core module.
110
+ * @param args - tool arguments.
111
+ * @param toolName - used in error messages.
112
+ * @returns `{ core, filePath, ext }`.
113
+ */
114
+ async function loadImageArgs(args, toolName) {
115
+ const filePath = String(args.file_path ?? '').trim();
116
+ if (filePath.length === 0) throw new Error(`${toolName}: file_path must be a non-empty string`);
117
+ const core = await importCore();
118
+ const ext = extname(filePath).toLowerCase();
119
+ if (core.UNSUPPORTED_EXTENSIONS.has(ext)) {
120
+ throw new Error(`${toolName}: WebP is not supported yet — convert the file to PNG or JPEG first`);
121
+ }
122
+ if (!core.IMAGE_EXTENSIONS.has(ext)) {
123
+ throw new Error(`${toolName}: unsupported image type "${ext}" (supported: PNG, JPEG, GIF, BMP)`);
124
+ }
125
+ return { core, filePath, ext };
126
+ }
127
+
128
+ /**
129
+ * Decode the file and enforce the pixel-count limit.
130
+ * @param core - the core module.
131
+ * @param data - file bytes.
132
+ * @param ext - lowercase extension including the dot.
133
+ * @param toolName - used in error messages.
134
+ * @returns `{ image, width, height }`.
135
+ */
136
+ function decodeBounded(core, data, ext, toolName) {
137
+ const image = core.decodeImage(data, ext);
138
+ if (image.width * image.height > MAX_PIXELS) {
139
+ throw new Error(
140
+ `${toolName}: ${image.width}x${image.height} exceeds the ${MAX_PIXELS}-pixel decode limit — downscale or crop the file first`
141
+ );
142
+ }
143
+ return image;
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // tool implementations
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * `image_scan`: read a local image as a coarse pixel grid (downscaled +
152
+ * color-quantized) so a text-only model can "see" layout, colors and shapes.
153
+ * @param args - tool arguments.
154
+ * @returns the analysis result (rendered by {@link renderScanResult}).
155
+ */
156
+ export async function executeScan(args) {
157
+ const { core, filePath, ext } = await loadImageArgs(args, 'image_scan');
158
+ if (args.region !== undefined && args.focus !== undefined) {
159
+ throw new Error('image_scan: region and focus are mutually exclusive — pass only one');
160
+ }
161
+ const mode = parseMode(args.mode);
162
+ const palette = core.resolvePaletteArgument(args.palette);
163
+ let pxPerCell;
164
+ if (args.px_per_cell !== undefined) {
165
+ pxPerCell = Number(args.px_per_cell);
166
+ if (!Number.isInteger(pxPerCell) || pxPerCell < 1 || pxPerCell > 512) {
167
+ throw new Error('image_scan: px_per_cell must be an integer between 1 and 512');
168
+ }
169
+ if (args.size !== undefined) {
170
+ throw new Error('image_scan: size and px_per_cell are mutually exclusive — pass only one');
171
+ }
172
+ }
173
+ const size = pxPerCell !== undefined ? 32 : parseSize(args.size);
174
+
175
+ const absolutePath = resolveImagePath(filePath);
176
+ const data = await readImageFile(absolutePath, 'image_scan');
177
+ const image = decodeBounded(core, data, ext, 'image_scan');
178
+
179
+ // focus uses grid coordinates against the full-image grid this size
180
+ // produces; region and focus are resolved after decode.
181
+ let regionArray;
182
+ let regionDisplay;
183
+ if (args.focus !== undefined) {
184
+ const fullGridHeight = Math.max(1, Math.round(size * (image.height / image.width)));
185
+ regionArray = core.resolveFocus(args.focus, size, fullGridHeight);
186
+ regionDisplay = `focus [${args.focus.map(String).join(',')}]`;
187
+ } else if (args.region !== undefined) {
188
+ regionArray = core.normalizeRegion(args.region);
189
+ regionDisplay = regionArray.map((v) => Math.round(v * 1000) / 1000).join(',');
190
+ } else {
191
+ regionDisplay = 'full';
192
+ }
193
+
194
+ const analysis = core.analyzeImage(image.data, image.width, image.height, { size, mode, region: regionArray, palette, pxPerCell });
195
+ return {
196
+ path: absolutePath,
197
+ width: image.width,
198
+ height: image.height,
199
+ region: regionDisplay,
200
+ ...analysis
201
+ };
202
+ }
203
+
204
+ /**
205
+ * `image_ocr`: recognize text in a local image (optionally within a
206
+ * region/focus). Windows OCR by default; PaddleOCR optional, with graceful
207
+ * fallback so it never crashes.
208
+ * @param args - tool arguments.
209
+ * @returns the OCR result (rendered by {@link renderOcrResult}).
210
+ */
211
+ export async function executeOcr(args) {
212
+ const { core, filePath, ext } = await loadImageArgs(args, 'image_ocr');
213
+ if (args.region !== undefined && args.focus !== undefined) {
214
+ throw new Error('image_ocr: region and focus are mutually exclusive — pass only one');
215
+ }
216
+ if (args.language !== undefined && String(args.language).trim().length === 0) {
217
+ throw new Error('image_ocr: language must be a non-empty BCP-47 tag');
218
+ }
219
+ const engine = args.engine === undefined ? 'windows' : String(args.engine);
220
+ if (engine !== 'windows' && engine !== 'paddle') {
221
+ throw new Error("image_ocr: engine must be 'windows' (default) or 'paddle'");
222
+ }
223
+
224
+ const absolutePath = resolveImagePath(filePath);
225
+ const data = await readImageFile(absolutePath, 'image_ocr');
226
+ const image = decodeBounded(core, data, ext, 'image_ocr');
227
+
228
+ let regionArray;
229
+ let regionDisplay;
230
+ if (args.focus !== undefined) {
231
+ const fullGridHeight = Math.max(1, Math.round(32 * (image.height / image.width)));
232
+ regionArray = core.resolveFocus(args.focus, 32, fullGridHeight);
233
+ regionDisplay = `focus [${args.focus.map(String).join(',')}]`;
234
+ } else if (args.region !== undefined) {
235
+ regionArray = core.normalizeRegion(args.region);
236
+ regionDisplay = regionArray.map((v) => Math.round(v * 1000) / 1000).join(',');
237
+ } else {
238
+ regionDisplay = 'full';
239
+ }
240
+
241
+ // PaddleOCR is an optional engine: degrade gracefully to the Windows
242
+ // engine (with a note) when it is missing or fails — never crash.
243
+ let effectiveEngine = engine;
244
+ let note;
245
+ if (engine === 'paddle' && !(await core.paddleAvailable())) {
246
+ effectiveEngine = 'windows';
247
+ note = 'PaddleOCR is not installed (engine="paddle" requested) — fell back to Windows OCR. To install it, run: node scripts/setup-ocr.mjs (see README).';
248
+ }
249
+ let result;
250
+ try {
251
+ result = await core.ocrImage(data, ext, {
252
+ region: regionArray,
253
+ language: args.language === undefined ? undefined : String(args.language).trim(),
254
+ engine: effectiveEngine
255
+ });
256
+ } catch (error) {
257
+ if (engine === 'paddle' && effectiveEngine === 'paddle') {
258
+ effectiveEngine = 'windows';
259
+ note = `PaddleOCR failed (${error.message.slice(0, 140)}) — fell back to Windows OCR.`;
260
+ result = await core.ocrImage(data, ext, {
261
+ region: regionArray,
262
+ language: args.language === undefined ? undefined : String(args.language).trim(),
263
+ engine: 'windows'
264
+ });
265
+ } else {
266
+ throw error;
267
+ }
268
+ }
269
+ if (result.downscaled === true) {
270
+ const capNote = `image downscaled to ${result.width}x${result.height} for PaddleOCR (long side capped at ${core.PADDLE_MAX_LONG_SIDE}px to keep calls fast); use region/focus for fine text`;
271
+ note = note === undefined ? capNote : `${note} ${capNote}`;
272
+ }
273
+ return {
274
+ path: absolutePath,
275
+ width: result.width,
276
+ height: result.height,
277
+ region: regionDisplay,
278
+ engine: effectiveEngine,
279
+ ...(note !== undefined ? { note } : {}),
280
+ lines: result.lines
281
+ };
282
+ }
283
+
284
+ /**
285
+ * `image_sample`: sample a small region as an NxN grid of EXACT pixels plus a
286
+ * local-contrast statistic, for material/texture judgment.
287
+ * @param args - tool arguments.
288
+ * @returns the sample result (rendered by {@link renderSampleResult}).
289
+ */
290
+ export async function executeSample(args) {
291
+ const { core, filePath, ext } = await loadImageArgs(args, 'image_sample');
292
+ if (args.region === undefined) throw new Error('image_sample: region is required ([x0, y0, x1, y1] fractions)');
293
+ const size = args.size === undefined ? 8 : Number(args.size);
294
+ if (!Number.isInteger(size) || size < 2 || size > 16) {
295
+ throw new Error('image_sample: size must be an integer between 2 and 16');
296
+ }
297
+ const regionArray = core.normalizeRegion(args.region);
298
+
299
+ const absolutePath = resolveImagePath(filePath);
300
+ const data = await readImageFile(absolutePath, 'image_sample');
301
+ const image = decodeBounded(core, data, ext, 'image_sample');
302
+
303
+ const sample = core.samplePixels(image.data, image.width, image.height, regionArray, size);
304
+ return {
305
+ path: absolutePath,
306
+ width: sample.width,
307
+ height: sample.height,
308
+ region: regionArray.map((v) => Math.round(v * 1000) / 1000).join(','),
309
+ contrast: sample.contrast,
310
+ distinct: sample.distinct,
311
+ stepX: sample.stepX,
312
+ stepY: sample.stepY,
313
+ points: sample.points
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Dispatch a tool call to its implementation.
319
+ * @param name - tool name ('image_scan' | 'image_ocr' | 'image_sample').
320
+ * @param args - tool arguments object.
321
+ * @returns the structured tool result.
322
+ */
323
+ export async function executeTool(name, args) {
324
+ const cleanArgs = args === undefined || args === null ? {} : args;
325
+ switch (name) {
326
+ case 'image_scan': return executeScan(cleanArgs);
327
+ case 'image_ocr': return executeOcr(cleanArgs);
328
+ case 'image_sample': return executeSample(cleanArgs);
329
+ default: throw new Error(`unknown tool: ${name}`);
330
+ }
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // tool registry (schema + descriptions for tools/list)
335
+ // ---------------------------------------------------------------------------
336
+
337
+ const IMAGE_PATH_DESCRIPTION = 'Path to the image file (PNG/JPEG/GIF/BMP). Absolute path, or relative to the project workspace (resolved against the workspace root).';
338
+
339
+ /** The MCP tool definitions, one per model-facing tool. */
340
+ export const TOOLS = [
341
+ {
342
+ name: 'image_scan',
343
+ description: [
344
+ 'Read a local image file as a coarse pixel grid (downscaled + color-quantized) so a text-only model can see layout, colors and rough shapes.',
345
+ 'Use it to inspect charts, screenshots, diagrams, UI mockups or photos: report dominant colors with percentages, relative positions of regions, coarse structure and luminance patterns.',
346
+ 'The result includes a luminance grid (rows top->bottom, columns left->right; " "=transparent, "." darkest, "@" brightest), a color grid for colorful images (one letter per cell, see legend), a "grid coords" line giving the row/col range, and a regions list: connected color blobs with position (grid rows/cols), size, aspect and texture density.',
347
+ 'Semantic reading: use the regions list plus your world knowledge to infer WHAT the image contains, not just raw colors — e.g. a large rough round green blob above a thin brown stem reads as a tree; a dense cluster of small bright blobs near the center with a dark smooth frame reads as a screen with content. Combine regions with the grids and zoom (focus/region) to verify.',
348
+ 'Realism judgment: the "shade diversity" line and each region\'s "N shade(s)" mix tell you how many hue+brightness variations an area has — 1-2 shades means flat/synthetic artwork (a sticker or diagram), many shades means photo-like content with lighting and gradients. Use this to say whether something looks drawn vs photographed.',
349
+ 'Structural hints are listed too: parallel stripes (alternating color bands) suggest panels/grilles/blades (e.g. solar panels, louvres, ribs); left-right symmetry suggests manufactured/constructed objects; smooth bright-to-dark gradients across a blob suggest curved surfaces (cylinders, spheres — e.g. a round module). Use these shape cues to identify objects, then verify with px_per_cell or image_sample on the area.',
350
+ 'To inspect details, work iteratively: first scan the full image (any size, default 32), identify the region you care about, then call image_scan again with focus: [row0, col0, row1, col1] — rows/cols are read from the "grid coords" line of that full scan, and you MUST keep size the SAME as that scan (focus itself provides the zoom: the same grid then covers only the focused area, so each cell shows finer detail). If you want even more detail, zoom again into a smaller focus inside the previous focused result, still with the same size. Alternatively pass region: [x0, y0, x1, y1] (0..1 fractions) which works with any size.',
351
+ 'For fine detail on a specific subject (a person, an object, a face): request a pixel density with px_per_cell — the number of source pixels each cell represents (e.g. px_per_cell: 4 makes every cell show a 4x4 pixel area). The tool clamps to 64 cells per side and reports the actual density in the header ("~XxYpx per cell"); if the region is too large for your requested density, shrink the region (zoom the focus) and retry. Use px_per_cell with region/focus, never for a whole huge image (too many cells).',
352
+ 'palette sets the color depth: auto (default, picks by content), full (14 colors), basic (8 colors) or gray (black/gray/white only).',
353
+ 'Note: "colors by area" reports TRUE pixel-level color shares (small colored details are never diluted away), and the "hue families" line breaks colors down by hue regardless of darkness — use it to spot pink/cyan/green content that a dark palette would otherwise hide (e.g. blossoms, water, vegetation).',
354
+ 'Limitation: no OCR/text recognition and no fine detail — thin lines and small glyphs may disappear at coarse sizes; zoom into a region to inspect details.',
355
+ 'size = target cells on the longer side (8..64, default 32). mode auto picks the color grid when the image is colorful.'
356
+ ].join(' '),
357
+ inputSchema: {
358
+ type: 'object',
359
+ additionalProperties: true,
360
+ properties: {
361
+ file_path: { type: 'string', description: IMAGE_PATH_DESCRIPTION },
362
+ size: {
363
+ type: 'integer',
364
+ description: 'Target cell count on the longer side (8..64, default 32). Mutually exclusive with px_per_cell.'
365
+ },
366
+ px_per_cell: {
367
+ type: 'integer',
368
+ description: 'Requested source pixels per cell for fine detail (e.g. 2-16); clamped to 64 cells per side, actual density reported in the header. Use with region/focus on a small area, mutually exclusive with size.'
369
+ },
370
+ mode: {
371
+ type: 'string',
372
+ enum: ['auto', 'ascii', 'color'],
373
+ description: 'auto = color grid when colorful, else luminance grid (default); ascii = luminance only; color = include color grid.'
374
+ },
375
+ palette: {
376
+ type: 'string',
377
+ enum: ['auto', 'full', 'basic', 'gray'],
378
+ description: 'Color depth: auto (default) = pick by content, full = 14 colors, basic = 8 colors, gray = black/gray/white only.'
379
+ },
380
+ region: {
381
+ type: 'array',
382
+ description: 'Optional [x0, y0, x1, y1] fractions in 0..1 to zoom into part of the image. Mutually exclusive with focus.',
383
+ items: { type: 'number' }
384
+ },
385
+ focus: {
386
+ type: 'array',
387
+ description: 'Zoom target as grid coordinates [row0, col0, row1, col1] (inclusive, based on the full-image grid the current size produces — read rows/cols from the "grid coords" line of a previous image_scan output). Mutually exclusive with region.',
388
+ items: { type: 'integer' }
389
+ }
390
+ },
391
+ required: ['file_path']
392
+ }
393
+ },
394
+ {
395
+ name: 'image_ocr',
396
+ description: [
397
+ 'Recognize text in a local image. Two engines: engine="windows" (default) uses the Windows built-in OCR (no install, good for printed/UI text); engine="paddle" uses PaddleOCR via the local paddle_venv (much better for glowing, curved, stylized or game-rendered text and complex backgrounds, Chinese-friendly; ~2s model load per call).',
398
+ 'Use it together with image_scan: when the pixel grid shows a dense, regular, high-contrast structure that looks like text (e.g. titles, labels, buttons, dialogs, glowing banners), call image_ocr on that region and read the actual characters. If the Windows engine returns nothing but text is expected, retry with engine="paddle".',
399
+ 'Parameters: file_path (required), region: [x0, y0, x1, y1] (0..1 fractions) or focus: [row0, col0, row1, col1] (grid coordinates) to restrict recognition to an area, language (optional BCP-47 tag like "zh-Hans" or "en-US", Windows engine only), engine ("windows" default, "paddle").',
400
+ 'Large images are automatically downscaled (long side capped at 1600px) for the paddle engine so calls stay fast; the result notes the downscale — crop with region/focus when you need fine text from a big image.',
401
+ 'The result lists each recognized line with its pixel bounding box and confidence score (paddle).'
402
+ ].join(' '),
403
+ inputSchema: {
404
+ type: 'object',
405
+ additionalProperties: true,
406
+ properties: {
407
+ file_path: { type: 'string', description: IMAGE_PATH_DESCRIPTION },
408
+ region: {
409
+ type: 'array',
410
+ description: 'Optional [x0, y0, x1, y1] fractions in 0..1 to restrict recognition to part of the image. Mutually exclusive with focus.',
411
+ items: { type: 'number' }
412
+ },
413
+ focus: {
414
+ type: 'array',
415
+ description: 'Optional [row0, col0, row1, col1] grid coordinates (inclusive) to restrict recognition to part of the image. Mutually exclusive with region.',
416
+ items: { type: 'integer' }
417
+ },
418
+ language: {
419
+ type: 'string',
420
+ description: 'Optional BCP-47 language tag (e.g. "zh-Hans", "en-US"); defaults to the user languages. Windows engine only.'
421
+ },
422
+ engine: {
423
+ type: 'string',
424
+ enum: ['windows', 'paddle'],
425
+ description: '"windows" (default) = Windows built-in OCR; "paddle" = PaddleOCR via local paddle_venv (better for glowing/curved/game text).'
426
+ }
427
+ },
428
+ required: ['file_path']
429
+ }
430
+ },
431
+ {
432
+ name: 'image_sample',
433
+ description: [
434
+ 'Sample a small region of a local image as an NxN grid of EXACT pixels (one real pixel per cell, not an average) plus a local-contrast statistic.',
435
+ 'Use it to judge MATERIAL or TEXTURE where a coarse grid is not enough: smooth color gradients (skin, sky, water), high-contrast stripes (metal, wood grain, brushed surfaces), periodic repeats (fabric, brick), high-frequency noise (foliage, gravel), sharp edges (screen content, UI).',
436
+ 'Workflow: first use image_scan to locate the area, then call image_sample with a SMALL region (e.g. [x0, y0, x1, y1] fractions covering roughly 30-400 px per side) and an optional size (2..16, default 8). The region must be at least `size` pixels in each direction.',
437
+ 'Interpret the returned RGB grid: row 0 is the top, left to right. High contrast with stripes suggests metal/wood/rough material; smooth low-contrast transitions suggest skin/sky/uniform surfaces; repetitive patterns suggest fabric/texture.'
438
+ ].join(' '),
439
+ inputSchema: {
440
+ type: 'object',
441
+ additionalProperties: true,
442
+ properties: {
443
+ file_path: { type: 'string', description: IMAGE_PATH_DESCRIPTION },
444
+ region: {
445
+ type: 'array',
446
+ description: 'Required [x0, y0, x1, y1] fractions in 0..1: the small area to sample. Must cover at least `size` pixels in each direction.',
447
+ items: { type: 'number' }
448
+ },
449
+ size: {
450
+ type: 'integer',
451
+ description: 'Sample grid side length (2..16, default 8); the output is size x size exact pixels.'
452
+ }
453
+ },
454
+ required: ['file_path', 'region']
455
+ }
456
+ }
457
+ ];
458
+
459
+ /** Render a structured tool result as the text block fed back to the model. */
460
+ async function renderResult(name, value) {
461
+ const core = await importCore();
462
+ const renderer =
463
+ name === 'image_scan' ? core.renderImageScan
464
+ : name === 'image_ocr' ? core.renderOcr
465
+ : name === 'image_sample' ? core.renderSample
466
+ : null;
467
+ if (renderer === null || renderer === undefined) {
468
+ return `${name} result for ${value.path}: ${JSON.stringify(value)}`;
469
+ }
470
+ return renderer(value);
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // MCP JSON-RPC plumbing
475
+ // ---------------------------------------------------------------------------
476
+
477
+ function rpcError(message, code = -32603) {
478
+ return { jsonrpc: '2.0', error: { code, message } };
479
+ }
480
+
481
+ /**
482
+ * Handle one decoded JSON-RPC message and produce the response (null for
483
+ * notifications, which never get a response).
484
+ * @param msg - parsed JSON-RPC message.
485
+ * @returns the response object, or null.
486
+ */
487
+ export async function handleRequest(msg) {
488
+ const id = msg?.id;
489
+ if (id === undefined || id === null) return null; // notification
490
+ try {
491
+ switch (msg.method) {
492
+ case 'initialize': {
493
+ return {
494
+ jsonrpc: '2.0',
495
+ id,
496
+ result: {
497
+ protocolVersion: msg.params?.protocolVersion ?? PROTOCOL_VERSION,
498
+ capabilities: { tools: { listChanged: false } },
499
+ serverInfo: { name: 'picturereader', version: '0.1.0' }
500
+ }
501
+ };
502
+ }
503
+ case 'ping':
504
+ return { jsonrpc: '2.0', id, result: {} };
505
+ case 'tools/list':
506
+ return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
507
+ case 'tools/call': {
508
+ const name = String(msg.params?.name ?? '');
509
+ const value = await executeTool(name, msg.params?.arguments);
510
+ const text = await renderResult(name, value);
511
+ return {
512
+ jsonrpc: '2.0',
513
+ id,
514
+ result: {
515
+ content: [{ type: 'text', text }],
516
+ structuredContent: value,
517
+ isError: false
518
+ }
519
+ };
520
+ }
521
+ default:
522
+ return { jsonrpc: '2.0', id, ...rpcError(`method not found: ${msg.method}`, -32601) };
523
+ }
524
+ } catch (error) {
525
+ // Tool failures are reported in-band (isError) so the client can surface
526
+ // the message to the model; protocol errors use JSON-RPC error codes.
527
+ if (msg.method === 'tools/call') {
528
+ return {
529
+ jsonrpc: '2.0',
530
+ id,
531
+ result: {
532
+ content: [{ type: 'text', text: `error: ${error.message}` }],
533
+ isError: true
534
+ }
535
+ };
536
+ }
537
+ return { jsonrpc: '2.0', id, ...rpcError(error.message) };
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Run the server loop over an input/output stream pair (defaults to
543
+ * stdin/stdout). Returns a dispose function that tears down the listeners.
544
+ * @param input - readable stream of newline-delimited JSON-RPC messages.
545
+ * @param output - writable stream for JSON-RPC responses.
546
+ * @param log - writable stream for diagnostics (stderr by default).
547
+ * @returns a function that closes the server.
548
+ */
549
+ export function runServer(input = process.stdin, output = process.stdout, log = process.stderr) {
550
+ const rl = createInterface({ input, crlfDelay: Infinity });
551
+ const onLine = (line) => {
552
+ const trimmed = line.trim();
553
+ if (trimmed.length === 0) return;
554
+ let msg;
555
+ try {
556
+ msg = JSON.parse(trimmed);
557
+ } catch {
558
+ log.write('picturereader: ignoring invalid JSON-RPC line\n');
559
+ return;
560
+ }
561
+ handleRequest(msg).then((response) => {
562
+ if (response !== null && response !== undefined) {
563
+ output.write(`${JSON.stringify(response)}\n`);
564
+ }
565
+ }).catch((error) => {
566
+ log.write(`picturereader: ${error.stack ?? error.message}\n`);
567
+ });
568
+ };
569
+ const onError = (error) => {
570
+ log.write(`picturereader: input error: ${error.message}\n`);
571
+ };
572
+ rl.on('line', onLine);
573
+ rl.on('error', onError);
574
+ log.write('picturereader MCP server ready (image_scan / image_ocr / image_sample)\n');
575
+ return () => {
576
+ rl.off('line', onLine);
577
+ rl.off('error', onError);
578
+ rl.close();
579
+ };
580
+ }
581
+
582
+ /**
583
+ * Run the stdio server. Only executes when this file is the entry point, so
584
+ * tests can import the functions above without starting a server.
585
+ */
586
+ export function main() {
587
+ const dispose = runServer();
588
+ process.stdin.on('end', dispose);
589
+ }
590
+
591
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
592
+ main();
593
+ }