@sylphx/image-reader-mcp 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,19 +1,24 @@
1
1
  {
2
2
  "name": "@sylphx/image-reader-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "mcpName": "io.github.SylphxAI/image-reader-mcp",
5
- "description": "Evidence-first image reading for AI agents metadata, OCR text, regions, and citeable evidence without generative LLM.",
5
+ "description": "Iris \u2014 Evidence-first image reading for AI agents \u2014 metadata, OCR text, regions, and citeable evidence without generative LLM.",
6
6
  "type": "module",
7
7
  "bin": {
8
- "image-reader-mcp": "./dist/index.js"
8
+ "image-reader-mcp": "./bin/image-reader-mcp",
9
+ "iris": "./bin/image-reader-mcp"
9
10
  },
10
11
  "files": [
12
+ "bin/",
11
13
  "dist/",
12
14
  "README.md",
13
- "LICENSE"
15
+ "LICENSE",
16
+ "src/"
14
17
  ],
15
18
  "exports": {
16
- ".": "./dist/index.js"
19
+ ".": "./dist/doctor-cli.js",
20
+ "./sdk": "./src/sdk.ts",
21
+ "./iris": "./src/sdk.ts"
17
22
  },
18
23
  "publishConfig": {
19
24
  "access": "public"
@@ -39,7 +44,9 @@
39
44
  "node": ">=22.13.0"
40
45
  },
41
46
  "scripts": {
42
- "build": "bunup --no-dts",
47
+ "build:rust": "cargo build --release -p image-reader-core -p image-reader-cli -p image-reader-mcp-server && bun scripts/stage-rust-mcp.ts",
48
+ "test:rust": "cargo test",
49
+ "build": "bun build src/doctor-cli.ts --outdir dist --target node",
43
50
  "watch": "tsc --watch",
44
51
  "test": "bun test",
45
52
  "test:watch": "bun test --watch",
@@ -49,12 +56,16 @@
49
56
  "check": "biome check .",
50
57
  "check:fix": "biome check --write .",
51
58
  "validate": "bun run check && bun run typecheck && bun run test",
59
+ "doctor": "bun run src/doctor-cli.ts",
60
+ "benchmark:release-gate": "bun scripts/release-gate.ts",
52
61
  "sync:server-json": "bun scripts/sync-server-json.ts",
53
62
  "version-packages": "changeset version && bun run sync:server-json",
54
- "start": "node dist/index.js",
63
+ "start": "./bin/image-reader-mcp",
55
64
  "clean": "rm -rf dist coverage",
56
- "prepublishOnly": "bun run clean && bun run build",
57
- "release": "bun run typecheck && bun run check && bun run build && bun test && changeset publish"
65
+ "prepublishOnly": "bun run clean && bun run build && bun run build:rust",
66
+ "release": "bun run typecheck && bun run check && bun run build && bun test && changeset publish",
67
+ "benchmark:public-proof": "bun scripts/public-proof.ts",
68
+ "brand:pack-plan": "bun scripts/brand-pack-plan.ts"
58
69
  },
59
70
  "dependencies": {
60
71
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -70,5 +81,6 @@
70
81
  "bunup": "0.16.31",
71
82
  "typescript": "^7.0.2"
72
83
  },
73
- "packageManager": "bun@1.3.12"
84
+ "packageManager": "bun@1.3.12",
85
+ "private": false
74
86
  }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from 'node:module';
4
+ import { formatDoctorReport, runDoctor } from './doctor.js';
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const packageJson = require('../package.json') as { version: string };
8
+
9
+ async function main(): Promise<void> {
10
+ const report = await runDoctor(packageJson.version);
11
+ console.log(formatDoctorReport(report));
12
+ process.exit(report.status === 'unavailable' ? 1 : 0);
13
+ }
14
+
15
+ main().catch((error: unknown) => {
16
+ console.error('[Image Reader MCP] Doctor error:', error);
17
+ process.exit(1);
18
+ });
package/src/doctor.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import sharp from 'sharp';
6
+ import { resolveRustCliBinary } from './engine/rust-decode.js';
7
+ import { listTesseractLanguages } from './utils/ocr.js';
8
+ import { IMAGE_SAFETY_LIMITS } from './utils/safety.js';
9
+
10
+ const here = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ export type DoctorStatus = 'ok' | 'warn' | 'fail';
13
+
14
+ export interface DoctorCheck {
15
+ id: string;
16
+ status: DoctorStatus;
17
+ message: string;
18
+ }
19
+
20
+ export interface DoctorReport {
21
+ profile: 'image_reader_doctor';
22
+ version: string;
23
+ status: 'ready' | 'degraded' | 'unavailable';
24
+ checks: DoctorCheck[];
25
+ }
26
+
27
+ const probeTesseract = (): DoctorCheck => {
28
+ const result = spawnSync('tesseract', ['--version'], {
29
+ encoding: 'utf8',
30
+ timeout: 2_500,
31
+ });
32
+
33
+ if (result.status === 0) {
34
+ const versionLine = (result.stdout || result.stderr || '').split('\n')[0]?.trim();
35
+ const langs = listTesseractLanguages();
36
+ const langNote = langs.available ? ` languages=[${langs.languages.join(',') || 'none'}]` : '';
37
+ return {
38
+ id: 'tesseract',
39
+ status: 'ok',
40
+ message: versionLine
41
+ ? `Tesseract available (${versionLine})${langNote}`
42
+ : `Tesseract available${langNote}`,
43
+ };
44
+ }
45
+
46
+ return {
47
+ id: 'tesseract',
48
+ status: 'warn',
49
+ message:
50
+ 'Tesseract is not installed. OCR is optional; read_image still returns dimensions and metadata.',
51
+ };
52
+ };
53
+
54
+ const probeSharp = async (): Promise<DoctorCheck> => {
55
+ try {
56
+ const buffer = await sharp({
57
+ create: {
58
+ width: 1,
59
+ height: 1,
60
+ channels: 3,
61
+ background: { r: 0, g: 0, b: 0 },
62
+ },
63
+ })
64
+ .png()
65
+ .toBuffer();
66
+
67
+ if (buffer.length === 0) {
68
+ return {
69
+ id: 'sharp',
70
+ status: 'fail',
71
+ message: 'sharp failed to produce a probe image buffer.',
72
+ };
73
+ }
74
+
75
+ return {
76
+ id: 'sharp',
77
+ status: 'ok',
78
+ message: `sharp decode pipeline is available (v${sharp.versions.sharp}).`,
79
+ };
80
+ } catch (error: unknown) {
81
+ const message = error instanceof Error ? error.message : String(error);
82
+ return {
83
+ id: 'sharp',
84
+ status: 'fail',
85
+ message: `sharp probe failed: ${message}`,
86
+ };
87
+ }
88
+ };
89
+
90
+ const probeSafetyLimits = (): DoctorCheck => {
91
+ if (IMAGE_SAFETY_LIMITS.maxFileBytes > 0 && IMAGE_SAFETY_LIMITS.maxPixels > 0) {
92
+ return {
93
+ id: 'safety_limits',
94
+ status: 'ok',
95
+ message: `Safety limits active: ${IMAGE_SAFETY_LIMITS.maxFileBytes} bytes, ${IMAGE_SAFETY_LIMITS.maxPixels} pixels.`,
96
+ };
97
+ }
98
+
99
+ return {
100
+ id: 'safety_limits',
101
+ status: 'fail',
102
+ message: 'Safety limits are not configured.',
103
+ };
104
+ };
105
+
106
+ const probeRustDecodeCli = (): DoctorCheck => {
107
+ const binary = resolveRustCliBinary();
108
+ if (binary !== 'image-reader-cli' && existsSync(binary)) {
109
+ return {
110
+ id: 'rust_decode_cli',
111
+ status: 'ok',
112
+ message: `Rust decode CLI is available at ${binary}.`,
113
+ };
114
+ }
115
+
116
+ const release = path.join(here, '../target/release/image-reader-cli');
117
+ const debug = path.join(here, '../target/debug/image-reader-cli');
118
+ if (existsSync(release) || existsSync(debug)) {
119
+ return {
120
+ id: 'rust_decode_cli',
121
+ status: 'ok',
122
+ message: 'Rust decode CLI is built locally.',
123
+ };
124
+ }
125
+
126
+ return {
127
+ id: 'rust_decode_cli',
128
+ status: 'warn',
129
+ message:
130
+ 'Rust decode CLI is not built. Run `cargo build --release` to enable the default Rust probe and region crop path.',
131
+ };
132
+ };
133
+
134
+ const probeRustDecodeFlag = (): DoctorCheck => {
135
+ if (process.env['IMAGE_READER_USE_RUST_DECODE'] === '0') {
136
+ return {
137
+ id: 'rust_decode_flag',
138
+ status: 'warn',
139
+ message: 'IMAGE_READER_USE_RUST_DECODE=0 forces the sharp fallback adapter path.',
140
+ };
141
+ }
142
+
143
+ if (process.env['IMAGE_READER_USE_RUST_DECODE'] === '1') {
144
+ return {
145
+ id: 'rust_decode_flag',
146
+ status: 'ok',
147
+ message:
148
+ 'IMAGE_READER_USE_RUST_DECODE=1 routes probe and region evidence through the Rust core.',
149
+ };
150
+ }
151
+
152
+ const binary = resolveRustCliBinary();
153
+ if (binary !== 'image-reader-cli' && existsSync(binary)) {
154
+ return {
155
+ id: 'rust_decode_flag',
156
+ status: 'ok',
157
+ message: 'Rust decode CLI is built; probe and region evidence default to the Rust core.',
158
+ };
159
+ }
160
+
161
+ return {
162
+ id: 'rust_decode_flag',
163
+ status: 'warn',
164
+ message:
165
+ 'Rust decode CLI is not built. sharp remains the default probe path until `cargo build --release`.',
166
+ };
167
+ };
168
+
169
+ const probeNode = (): DoctorCheck => {
170
+ const version = process.versions.node;
171
+ const major = Number.parseInt(version.split('.')[0] ?? '0', 10);
172
+ if (major >= 22) {
173
+ return {
174
+ id: 'node',
175
+ status: 'ok',
176
+ message: `Node.js ${version} meets the >=22.13 requirement.`,
177
+ };
178
+ }
179
+
180
+ return {
181
+ id: 'node',
182
+ status: 'warn',
183
+ message: `Node.js ${version} is below the recommended >=22.13 runtime.`,
184
+ };
185
+ };
186
+
187
+ const aggregateStatus = (checks: DoctorCheck[]): DoctorReport['status'] => {
188
+ if (checks.some((check) => check.status === 'fail')) {
189
+ return 'unavailable';
190
+ }
191
+ if (checks.some((check) => check.status === 'warn')) {
192
+ return 'degraded';
193
+ }
194
+ return 'ready';
195
+ };
196
+
197
+ export async function runDoctor(version: string): Promise<DoctorReport> {
198
+ const checks = [
199
+ probeNode(),
200
+ probeSafetyLimits(),
201
+ probeRustDecodeCli(),
202
+ probeRustDecodeFlag(),
203
+ await probeSharp(),
204
+ probeTesseract(),
205
+ ];
206
+ return {
207
+ profile: 'image_reader_doctor',
208
+ version,
209
+ status: aggregateStatus(checks),
210
+ checks,
211
+ };
212
+ }
213
+
214
+ export function formatDoctorReport(report: DoctorReport): string {
215
+ return JSON.stringify(report, null, 2);
216
+ }
@@ -0,0 +1,145 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import type { BoundingBox } from '../schemas/readImage.js';
6
+ import { ErrorCode, ImageError } from '../utils/errors.js';
7
+
8
+ export type RustImageProbe = {
9
+ format: string;
10
+ mime: string;
11
+ width: number;
12
+ height: number;
13
+ pixelCount: number;
14
+ hasAlpha: boolean;
15
+ colorType: string;
16
+ sourceHash: string;
17
+ fileSize: number;
18
+ route: string;
19
+ };
20
+
21
+ export type RustRegionEvidence = {
22
+ bbox: BoundingBox;
23
+ width: number;
24
+ height: number;
25
+ pixelCount: number;
26
+ regionHash: string;
27
+ mime: string;
28
+ route: string;
29
+ resized: boolean;
30
+ imageBase64?: string;
31
+ };
32
+
33
+ type RustProbeEnvelope =
34
+ | { status: 'ok'; probe: RustImageProbe }
35
+ | { status: 'error'; code: string; message: string };
36
+
37
+ type RustCropEnvelope =
38
+ | { status: 'ok'; region_evidence: RustRegionEvidence }
39
+ | { status: 'error'; code: string; message: string };
40
+
41
+ const here = path.dirname(fileURLToPath(import.meta.url));
42
+
43
+ export function resolveRustCliBinary(): string {
44
+ const env = process.env['IMAGE_READER_CLI'];
45
+ if (env && existsSync(env)) {
46
+ return env;
47
+ }
48
+
49
+ const release = path.join(here, '../../target/release/image-reader-cli');
50
+ if (existsSync(release)) {
51
+ return release;
52
+ }
53
+
54
+ const debug = path.join(here, '../../target/debug/image-reader-cli');
55
+ if (existsSync(debug)) {
56
+ return debug;
57
+ }
58
+
59
+ return 'image-reader-cli';
60
+ }
61
+
62
+ export function isRustCliAvailable(): boolean {
63
+ return resolveRustCliBinary() !== 'image-reader-cli';
64
+ }
65
+
66
+ export function shouldUseRustDecodeEngine(): boolean {
67
+ if (process.env['IMAGE_READER_USE_RUST_DECODE'] === '0') {
68
+ return false;
69
+ }
70
+ if (process.env['IMAGE_READER_USE_RUST_DECODE'] === '1') {
71
+ return true;
72
+ }
73
+ return isRustCliAvailable();
74
+ }
75
+
76
+ const invokeRustCli = (tool: string, input: Record<string, unknown>): unknown => {
77
+ const binary = resolveRustCliBinary();
78
+ const payload = JSON.stringify({ tool, input });
79
+
80
+ const result = spawnSync(binary, [], {
81
+ input: payload,
82
+ encoding: 'utf8',
83
+ maxBuffer: 16 * 1024 * 1024,
84
+ });
85
+
86
+ if (result.error) {
87
+ throw new ImageError(
88
+ ErrorCode.InvalidRequest,
89
+ `Failed to launch image decode engine: ${result.error.message}`
90
+ );
91
+ }
92
+
93
+ if (result.status !== 0) {
94
+ throw new ImageError(
95
+ ErrorCode.InvalidRequest,
96
+ result.stderr || `Image decode engine exited with status ${result.status}`
97
+ );
98
+ }
99
+
100
+ return JSON.parse(result.stdout) as unknown;
101
+ };
102
+
103
+ const mapErrorCode = (code: string): ErrorCode =>
104
+ code === 'INVALID_PARAMS' ? ErrorCode.InvalidParams : ErrorCode.InvalidRequest;
105
+
106
+ export function probeImageViaRustEngine(filePath: string, maxFileBytes: number): RustImageProbe {
107
+ const envelope = invokeRustCli('image_probe', {
108
+ path: filePath,
109
+ max_file_bytes: maxFileBytes,
110
+ }) as RustProbeEnvelope;
111
+
112
+ if (envelope.status !== 'ok') {
113
+ throw new ImageError(mapErrorCode(envelope.code), envelope.message);
114
+ }
115
+
116
+ return envelope.probe;
117
+ }
118
+
119
+ export function cropRegionViaRustEngine(input: {
120
+ filePath: string;
121
+ maxFileBytes: number;
122
+ maxPixels: number;
123
+ region: BoundingBox;
124
+ maxRegionDimension?: number | undefined;
125
+ includeRegionImage?: boolean | undefined;
126
+ }): RustRegionEvidence {
127
+ const envelope = invokeRustCli('crop_region', {
128
+ path: input.filePath,
129
+ max_file_bytes: input.maxFileBytes,
130
+ max_pixels: input.maxPixels,
131
+ region: input.region,
132
+ ...(input.maxRegionDimension !== undefined
133
+ ? { max_region_dimension: input.maxRegionDimension }
134
+ : {}),
135
+ ...(input.includeRegionImage !== undefined
136
+ ? { include_region_image: input.includeRegionImage }
137
+ : {}),
138
+ }) as RustCropEnvelope;
139
+
140
+ if (envelope.status !== 'ok') {
141
+ throw new ImageError(mapErrorCode(envelope.code), envelope.message);
142
+ }
143
+
144
+ return envelope.region_evidence;
145
+ }
@@ -0,0 +1,255 @@
1
+ import fs, { stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import exifr from 'exifr';
4
+ import sharp from 'sharp';
5
+ import {
6
+ cropRegionViaRustEngine,
7
+ probeImageViaRustEngine,
8
+ shouldUseRustDecodeEngine,
9
+ } from '../engine/rust-decode.js';
10
+ import { text, tool, toolError } from '../mcp.js';
11
+ import { type AgentMediaTwin, readImageArgsSchema } from '../schemas/readImage.js';
12
+ import { applyImageIntelligence } from '../utils/applyImageIntelligence.js';
13
+ import { ErrorCode, ImageError } from '../utils/errors.js';
14
+ import { collectTrustWarnings, redactGpsFields } from '../utils/metadata.js';
15
+ import { runTesseractOcr } from '../utils/ocr.js';
16
+ import { resolvePath } from '../utils/pathUtils.js';
17
+ import { IMAGE_SAFETY_LIMITS, validateImageSafety } from '../utils/safety.js';
18
+
19
+ const mimeFromFormat = (format: string | undefined): string => {
20
+ switch (format) {
21
+ case 'jpeg':
22
+ return 'image/jpeg';
23
+ case 'png':
24
+ return 'image/png';
25
+ case 'webp':
26
+ return 'image/webp';
27
+ case 'gif':
28
+ return 'image/gif';
29
+ case 'tiff':
30
+ return 'image/tiff';
31
+ case 'avif':
32
+ return 'image/avif';
33
+ case 'heif':
34
+ return 'image/heif';
35
+ default:
36
+ return format ? `image/${format}` : 'application/octet-stream';
37
+ }
38
+ };
39
+
40
+ const readMetadata = async (
41
+ filePath: string,
42
+ includeMetadata: boolean
43
+ ): Promise<{ metadata?: Record<string, unknown>; trustWarnings: string[] }> => {
44
+ if (!includeMetadata) {
45
+ return { trustWarnings: [] };
46
+ }
47
+
48
+ try {
49
+ const parsed = await exifr.parse(filePath, {
50
+ tiff: true,
51
+ xmp: true,
52
+ iptc: true,
53
+ icc: false,
54
+ jfif: false,
55
+ ihdr: false,
56
+ mergeOutput: true,
57
+ });
58
+
59
+ if (!parsed || typeof parsed !== 'object' || Object.keys(parsed).length === 0) {
60
+ return {
61
+ trustWarnings: ['No EXIF, XMP, or IPTC metadata was found in this image.'],
62
+ };
63
+ }
64
+
65
+ const rawMetadata = parsed as Record<string, unknown>;
66
+ const { metadata, hadGps } = redactGpsFields(rawMetadata);
67
+ const trustWarnings = collectTrustWarnings(rawMetadata, hadGps);
68
+ return { metadata, trustWarnings };
69
+ } catch {
70
+ return {
71
+ trustWarnings: ['Metadata extraction failed or metadata is not present in this image.'],
72
+ };
73
+ }
74
+ };
75
+
76
+ export const readImage = tool()
77
+ .description(
78
+ 'Evidence-first image reader for agents (read, not vague vision). Returns Agent Media Twin: geometry, metadata, OCR lines/words, layout blocks, text agent_map, optional palette, optional non-authority LLM caption. Local-first; generative path off by default.'
79
+ )
80
+ .input(readImageArgsSchema)
81
+ .handler(async ({ input }) => {
82
+ let resolvedPath: string;
83
+
84
+ try {
85
+ resolvedPath = resolvePath(input.path);
86
+ } catch (error: unknown) {
87
+ if (error instanceof ImageError) {
88
+ return toolError(error.message);
89
+ }
90
+ throw error;
91
+ }
92
+
93
+ try {
94
+ await fs.access(resolvedPath);
95
+ } catch (error: unknown) {
96
+ const message = error instanceof Error ? error.message : 'File not found.';
97
+ return toolError(`Unable to read image at '${input.path}': ${message}`);
98
+ }
99
+
100
+ try {
101
+ const fileStat = await stat(resolvedPath);
102
+ validateImageSafety({ fileSizeBytes: fileStat.size });
103
+
104
+ const includeMetadata = input.include_metadata ?? true;
105
+ const includeOcr = input.include_ocr ?? false;
106
+ const ocrLanguages = input.ocr_languages ?? ['eng'];
107
+ const useRustDecode = shouldUseRustDecodeEngine();
108
+
109
+ let twin: AgentMediaTwin;
110
+
111
+ if (useRustDecode) {
112
+ const probe = probeImageViaRustEngine(resolvedPath, IMAGE_SAFETY_LIMITS.maxFileBytes);
113
+ validateImageSafety({
114
+ fileSizeBytes: probe.fileSize,
115
+ width: probe.width,
116
+ height: probe.height,
117
+ });
118
+
119
+ const { metadata: extractedMetadata, trustWarnings } = await readMetadata(
120
+ resolvedPath,
121
+ includeMetadata
122
+ );
123
+
124
+ twin = {
125
+ filename: path.basename(resolvedPath),
126
+ mime: probe.mime,
127
+ dimensions: {
128
+ width: probe.width,
129
+ height: probe.height,
130
+ },
131
+ has_alpha: probe.hasAlpha,
132
+ color_space: probe.colorType,
133
+ trust_warnings: [
134
+ `Decode route: ${probe.route} (source hash ${probe.sourceHash.slice(0, 12)}…).`,
135
+ ...trustWarnings,
136
+ ],
137
+ };
138
+
139
+ if (extractedMetadata !== undefined) {
140
+ twin.metadata = extractedMetadata;
141
+ }
142
+ } else {
143
+ const image = sharp(resolvedPath, { failOn: 'none' });
144
+ const metadata = await image.metadata();
145
+ validateImageSafety({
146
+ fileSizeBytes: fileStat.size,
147
+ width: metadata.width,
148
+ height: metadata.height,
149
+ });
150
+
151
+ const { metadata: extractedMetadata, trustWarnings } = await readMetadata(
152
+ resolvedPath,
153
+ includeMetadata
154
+ );
155
+
156
+ twin = {
157
+ filename: path.basename(resolvedPath),
158
+ mime: mimeFromFormat(metadata.format),
159
+ dimensions: {
160
+ width: metadata.width ?? 0,
161
+ height: metadata.height ?? 0,
162
+ },
163
+ trust_warnings: [...trustWarnings],
164
+ };
165
+
166
+ if (metadata.orientation !== undefined) {
167
+ twin.orientation = metadata.orientation;
168
+ }
169
+ if (metadata.space !== undefined) {
170
+ twin.color_space = metadata.space;
171
+ }
172
+ if (metadata.hasAlpha !== undefined) {
173
+ twin.has_alpha = metadata.hasAlpha;
174
+ }
175
+
176
+ if (extractedMetadata !== undefined) {
177
+ twin.metadata = extractedMetadata;
178
+ }
179
+ }
180
+ if (twin.dimensions.width <= 0 || twin.dimensions.height <= 0) {
181
+ throw new ImageError(
182
+ ErrorCode.InvalidRequest,
183
+ `Unable to determine image dimensions for '${input.path}'.`
184
+ );
185
+ }
186
+
187
+ if (includeOcr) {
188
+ const ocr = runTesseractOcr(resolvedPath, {
189
+ languages: ocrLanguages,
190
+ minConfidence: input.ocr_min_confidence ?? 0,
191
+ includeWords: input.include_ocr_words ?? false,
192
+ });
193
+ twin.ocr = {
194
+ available: ocr.available,
195
+ lines: ocr.lines,
196
+ route: ocr.route,
197
+ languages: ocr.languages,
198
+ line_count: ocr.line_count,
199
+ dropped_low_confidence: ocr.dropped_low_confidence,
200
+ ...(ocr.skipped_reason !== undefined ? { skipped_reason: ocr.skipped_reason } : {}),
201
+ ...(ocr.languages_warning !== undefined
202
+ ? { languages_warning: ocr.languages_warning }
203
+ : {}),
204
+ ...(ocr.words !== undefined ? { words: ocr.words } : {}),
205
+ };
206
+ }
207
+
208
+ twin = await applyImageIntelligence(twin, resolvedPath, input, includeOcr);
209
+
210
+ if (input.region !== undefined) {
211
+ if (!useRustDecode) {
212
+ throw new ImageError(
213
+ ErrorCode.InvalidRequest,
214
+ 'Region evidence requires the Rust decode engine. Build image-reader-cli or set IMAGE_READER_USE_RUST_DECODE=1.'
215
+ );
216
+ }
217
+
218
+ const evidence = cropRegionViaRustEngine({
219
+ filePath: resolvedPath,
220
+ maxFileBytes: IMAGE_SAFETY_LIMITS.maxFileBytes,
221
+ maxPixels: IMAGE_SAFETY_LIMITS.maxPixels,
222
+ region: input.region,
223
+ ...(input.max_region_dimension !== undefined
224
+ ? { maxRegionDimension: input.max_region_dimension }
225
+ : {}),
226
+ includeRegionImage: input.include_region_image ?? false,
227
+ });
228
+
229
+ twin.region_evidence = {
230
+ bbox: evidence.bbox,
231
+ dimensions: {
232
+ width: evidence.width,
233
+ height: evidence.height,
234
+ },
235
+ region_hash: evidence.regionHash,
236
+ mime: evidence.mime,
237
+ route: evidence.route,
238
+ ...(evidence.resized ? { resized: evidence.resized } : {}),
239
+ ...(evidence.imageBase64 !== undefined ? { image_base64: evidence.imageBase64 } : {}),
240
+ };
241
+ twin.trust_warnings.push(
242
+ `Region evidence: ${evidence.route} (hash ${evidence.regionHash.slice(0, 12)}…).`
243
+ );
244
+ }
245
+
246
+ return text(JSON.stringify(twin, null, 2));
247
+ } catch (error: unknown) {
248
+ if (error instanceof ImageError) {
249
+ return toolError(error.message);
250
+ }
251
+
252
+ const message = error instanceof Error ? error.message : 'Unknown image read failure.';
253
+ return toolError(`Failed to read image '${input.path}': ${message}`);
254
+ }
255
+ });