@sylphx/image-reader-mcp 0.1.0 → 0.1.1
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/README.md +222 -16
- package/bin/image-reader-mcp +48 -0
- package/bin/native/image-reader-mcp-server +0 -0
- package/dist/doctor-cli.js +6705 -0
- package/package.json +22 -10
- package/src/doctor-cli.ts +18 -0
- package/src/doctor.ts +216 -0
- package/src/engine/rust-decode.ts +145 -0
- package/src/handlers/readImage.ts +252 -0
- package/src/mcp.ts +302 -0
- package/src/schemas/readImage.ts +110 -0
- package/src/sdk.ts +27 -0
- package/src/utils/errors.ts +18 -0
- package/src/utils/metadata.ts +108 -0
- package/src/utils/ocr.ts +279 -0
- package/src/utils/pathUtils.ts +40 -0
- package/src/utils/safety.ts +29 -0
- package/dist/index.js +0 -624
package/package.json
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/image-reader-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"mcpName": "io.github.SylphxAI/image-reader-mcp",
|
|
5
|
-
"description": "Evidence-first image reading for AI agents
|
|
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": "./
|
|
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/
|
|
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": "
|
|
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": "
|
|
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,252 @@
|
|
|
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 { ErrorCode, ImageError } from '../utils/errors.js';
|
|
13
|
+
import { collectTrustWarnings, redactGpsFields } from '../utils/metadata.js';
|
|
14
|
+
import { runTesseractOcr } from '../utils/ocr.js';
|
|
15
|
+
import { resolvePath } from '../utils/pathUtils.js';
|
|
16
|
+
import { IMAGE_SAFETY_LIMITS, validateImageSafety } from '../utils/safety.js';
|
|
17
|
+
|
|
18
|
+
const mimeFromFormat = (format: string | undefined): string => {
|
|
19
|
+
switch (format) {
|
|
20
|
+
case 'jpeg':
|
|
21
|
+
return 'image/jpeg';
|
|
22
|
+
case 'png':
|
|
23
|
+
return 'image/png';
|
|
24
|
+
case 'webp':
|
|
25
|
+
return 'image/webp';
|
|
26
|
+
case 'gif':
|
|
27
|
+
return 'image/gif';
|
|
28
|
+
case 'tiff':
|
|
29
|
+
return 'image/tiff';
|
|
30
|
+
case 'avif':
|
|
31
|
+
return 'image/avif';
|
|
32
|
+
case 'heif':
|
|
33
|
+
return 'image/heif';
|
|
34
|
+
default:
|
|
35
|
+
return format ? `image/${format}` : 'application/octet-stream';
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const readMetadata = async (
|
|
40
|
+
filePath: string,
|
|
41
|
+
includeMetadata: boolean
|
|
42
|
+
): Promise<{ metadata?: Record<string, unknown>; trustWarnings: string[] }> => {
|
|
43
|
+
if (!includeMetadata) {
|
|
44
|
+
return { trustWarnings: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const parsed = await exifr.parse(filePath, {
|
|
49
|
+
tiff: true,
|
|
50
|
+
xmp: true,
|
|
51
|
+
iptc: true,
|
|
52
|
+
icc: false,
|
|
53
|
+
jfif: false,
|
|
54
|
+
ihdr: false,
|
|
55
|
+
mergeOutput: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!parsed || typeof parsed !== 'object' || Object.keys(parsed).length === 0) {
|
|
59
|
+
return {
|
|
60
|
+
trustWarnings: ['No EXIF, XMP, or IPTC metadata was found in this image.'],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rawMetadata = parsed as Record<string, unknown>;
|
|
65
|
+
const { metadata, hadGps } = redactGpsFields(rawMetadata);
|
|
66
|
+
const trustWarnings = collectTrustWarnings(rawMetadata, hadGps);
|
|
67
|
+
return { metadata, trustWarnings };
|
|
68
|
+
} catch {
|
|
69
|
+
return {
|
|
70
|
+
trustWarnings: ['Metadata extraction failed or metadata is not present in this image.'],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const readImage = tool()
|
|
76
|
+
.description(
|
|
77
|
+
'Evidence-first image reader. Returns an Agent Media Twin with filename, mime, dimensions, metadata, optional OCR lines with bounding boxes, and trust warnings. No generative LLM is used.'
|
|
78
|
+
)
|
|
79
|
+
.input(readImageArgsSchema)
|
|
80
|
+
.handler(async ({ input }) => {
|
|
81
|
+
let resolvedPath: string;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
resolvedPath = resolvePath(input.path);
|
|
85
|
+
} catch (error: unknown) {
|
|
86
|
+
if (error instanceof ImageError) {
|
|
87
|
+
return toolError(error.message);
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await fs.access(resolvedPath);
|
|
94
|
+
} catch (error: unknown) {
|
|
95
|
+
const message = error instanceof Error ? error.message : 'File not found.';
|
|
96
|
+
return toolError(`Unable to read image at '${input.path}': ${message}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const fileStat = await stat(resolvedPath);
|
|
101
|
+
validateImageSafety({ fileSizeBytes: fileStat.size });
|
|
102
|
+
|
|
103
|
+
const includeMetadata = input.include_metadata ?? true;
|
|
104
|
+
const includeOcr = input.include_ocr ?? false;
|
|
105
|
+
const ocrLanguages = input.ocr_languages ?? ['eng'];
|
|
106
|
+
const useRustDecode = shouldUseRustDecodeEngine();
|
|
107
|
+
|
|
108
|
+
let twin: AgentMediaTwin;
|
|
109
|
+
|
|
110
|
+
if (useRustDecode) {
|
|
111
|
+
const probe = probeImageViaRustEngine(resolvedPath, IMAGE_SAFETY_LIMITS.maxFileBytes);
|
|
112
|
+
validateImageSafety({
|
|
113
|
+
fileSizeBytes: probe.fileSize,
|
|
114
|
+
width: probe.width,
|
|
115
|
+
height: probe.height,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const { metadata: extractedMetadata, trustWarnings } = await readMetadata(
|
|
119
|
+
resolvedPath,
|
|
120
|
+
includeMetadata
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
twin = {
|
|
124
|
+
filename: path.basename(resolvedPath),
|
|
125
|
+
mime: probe.mime,
|
|
126
|
+
dimensions: {
|
|
127
|
+
width: probe.width,
|
|
128
|
+
height: probe.height,
|
|
129
|
+
},
|
|
130
|
+
has_alpha: probe.hasAlpha,
|
|
131
|
+
color_space: probe.colorType,
|
|
132
|
+
trust_warnings: [
|
|
133
|
+
`Decode route: ${probe.route} (source hash ${probe.sourceHash.slice(0, 12)}…).`,
|
|
134
|
+
...trustWarnings,
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (extractedMetadata !== undefined) {
|
|
139
|
+
twin.metadata = extractedMetadata;
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
const image = sharp(resolvedPath, { failOn: 'none' });
|
|
143
|
+
const metadata = await image.metadata();
|
|
144
|
+
validateImageSafety({
|
|
145
|
+
fileSizeBytes: fileStat.size,
|
|
146
|
+
width: metadata.width,
|
|
147
|
+
height: metadata.height,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const { metadata: extractedMetadata, trustWarnings } = await readMetadata(
|
|
151
|
+
resolvedPath,
|
|
152
|
+
includeMetadata
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
twin = {
|
|
156
|
+
filename: path.basename(resolvedPath),
|
|
157
|
+
mime: mimeFromFormat(metadata.format),
|
|
158
|
+
dimensions: {
|
|
159
|
+
width: metadata.width ?? 0,
|
|
160
|
+
height: metadata.height ?? 0,
|
|
161
|
+
},
|
|
162
|
+
trust_warnings: [...trustWarnings],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (metadata.orientation !== undefined) {
|
|
166
|
+
twin.orientation = metadata.orientation;
|
|
167
|
+
}
|
|
168
|
+
if (metadata.space !== undefined) {
|
|
169
|
+
twin.color_space = metadata.space;
|
|
170
|
+
}
|
|
171
|
+
if (metadata.hasAlpha !== undefined) {
|
|
172
|
+
twin.has_alpha = metadata.hasAlpha;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (extractedMetadata !== undefined) {
|
|
176
|
+
twin.metadata = extractedMetadata;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (twin.dimensions.width <= 0 || twin.dimensions.height <= 0) {
|
|
180
|
+
throw new ImageError(
|
|
181
|
+
ErrorCode.InvalidRequest,
|
|
182
|
+
`Unable to determine image dimensions for '${input.path}'.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (includeOcr) {
|
|
187
|
+
const ocr = runTesseractOcr(resolvedPath, {
|
|
188
|
+
languages: ocrLanguages,
|
|
189
|
+
minConfidence: input.ocr_min_confidence ?? 0,
|
|
190
|
+
includeWords: input.include_ocr_words ?? false,
|
|
191
|
+
});
|
|
192
|
+
twin.ocr = {
|
|
193
|
+
available: ocr.available,
|
|
194
|
+
lines: ocr.lines,
|
|
195
|
+
route: ocr.route,
|
|
196
|
+
languages: ocr.languages,
|
|
197
|
+
line_count: ocr.line_count,
|
|
198
|
+
dropped_low_confidence: ocr.dropped_low_confidence,
|
|
199
|
+
...(ocr.skipped_reason !== undefined ? { skipped_reason: ocr.skipped_reason } : {}),
|
|
200
|
+
...(ocr.languages_warning !== undefined
|
|
201
|
+
? { languages_warning: ocr.languages_warning }
|
|
202
|
+
: {}),
|
|
203
|
+
...(ocr.words !== undefined ? { words: ocr.words } : {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (input.region !== undefined) {
|
|
208
|
+
if (!useRustDecode) {
|
|
209
|
+
throw new ImageError(
|
|
210
|
+
ErrorCode.InvalidRequest,
|
|
211
|
+
'Region evidence requires the Rust decode engine. Build image-reader-cli or set IMAGE_READER_USE_RUST_DECODE=1.'
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const evidence = cropRegionViaRustEngine({
|
|
216
|
+
filePath: resolvedPath,
|
|
217
|
+
maxFileBytes: IMAGE_SAFETY_LIMITS.maxFileBytes,
|
|
218
|
+
maxPixels: IMAGE_SAFETY_LIMITS.maxPixels,
|
|
219
|
+
region: input.region,
|
|
220
|
+
...(input.max_region_dimension !== undefined
|
|
221
|
+
? { maxRegionDimension: input.max_region_dimension }
|
|
222
|
+
: {}),
|
|
223
|
+
includeRegionImage: input.include_region_image ?? false,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
twin.region_evidence = {
|
|
227
|
+
bbox: evidence.bbox,
|
|
228
|
+
dimensions: {
|
|
229
|
+
width: evidence.width,
|
|
230
|
+
height: evidence.height,
|
|
231
|
+
},
|
|
232
|
+
region_hash: evidence.regionHash,
|
|
233
|
+
mime: evidence.mime,
|
|
234
|
+
route: evidence.route,
|
|
235
|
+
...(evidence.resized ? { resized: evidence.resized } : {}),
|
|
236
|
+
...(evidence.imageBase64 !== undefined ? { image_base64: evidence.imageBase64 } : {}),
|
|
237
|
+
};
|
|
238
|
+
twin.trust_warnings.push(
|
|
239
|
+
`Region evidence: ${evidence.route} (hash ${evidence.regionHash.slice(0, 12)}…).`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return text(JSON.stringify(twin, null, 2));
|
|
244
|
+
} catch (error: unknown) {
|
|
245
|
+
if (error instanceof ImageError) {
|
|
246
|
+
return toolError(error.message);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const message = error instanceof Error ? error.message : 'Unknown image read failure.';
|
|
250
|
+
return toolError(`Failed to read image '${input.path}': ${message}`);
|
|
251
|
+
}
|
|
252
|
+
});
|