@sylphx/image-reader-mcp 0.1.2 → 0.1.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/README.md +5 -3
- package/bin/image-reader-mcp +80 -9
- package/dist/doctor-cli.js +44 -35
- package/package.json +12 -6
- package/src/doctor.ts +22 -3
- package/src/handlers/readImage.ts +40 -2
- package/src/schemas/readImage.ts +16 -2
- package/src/utils/agentMap.ts +1 -0
- package/src/utils/applyImageIntelligence.ts +21 -5
- package/src/utils/layout.ts +50 -3
- package/src/utils/ocr.ts +114 -34
- package/src/utils/optionalLlm.ts +185 -24
- package/src/utils/palette.ts +46 -31
- package/bin/native/image-reader-mcp-server +0 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
### Image evidence for agents
|
|
6
6
|
|
|
7
|
-
**Iris** (transitional package `@sylphx/image-reader-mcp`) —
|
|
7
|
+
**Iris** (transitional package `@sylphx/image-reader-mcp`) — **Rust-first** local image facts, not default VLM guesses. `sharp`/`exifr` are optional, not required for MCP.
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<img src="https://mark.sylphx.com/api/v1/banner?type=holo&theme=tokyonight&text=image+reader+mcp&desc=Evidence-first+image+reading+for+AI+agents+%E2%80%94+metadata%2C+OCR+text%2C+regions%2C+and+ci&height=200&animation=rise&credit=0" alt="image-reader-mcp — Sylphx Mark banner" width="100%" />
|
|
@@ -43,6 +43,8 @@ Iris is **local-first**: geometry + OCR + **layout blocks** + **agent_map** so a
|
|
|
43
43
|
|
|
44
44
|
Spec: [docs/specs/agent-image-read-contract.md](docs/specs/agent-image-read-contract.md)
|
|
45
45
|
|
|
46
|
+
**Local-first frontier:** Rust decode, Tesseract native layout (no npm ML), optional Ollama VLM; cloud URL optional. Zero API key.
|
|
47
|
+
|
|
46
48
|
## Product docs
|
|
47
49
|
|
|
48
50
|
| Doc | Purpose |
|
|
@@ -76,7 +78,7 @@ an image, not a creative caption.**
|
|
|
76
78
|
| Paraphrased OCR | Optional Tesseract lines with bounding boxes and confidence |
|
|
77
79
|
| GPS and EXIF leak into context | GPS redacted; trust warnings for suspicious metadata |
|
|
78
80
|
| No provenance | Agent Media Twin JSON with measurable, citeable fields |
|
|
79
|
-
| Cloud API by default | **Local-first** —
|
|
81
|
+
| Cloud API by default | **Local-first** — Rust decode + optional Tesseract OCR; sharp/exifr only optional fallbacks |
|
|
80
82
|
| Ship and pray | **23** unit tests on schema, metadata, OCR hooks, safety limits, doctor, and release gate |
|
|
81
83
|
|
|
82
84
|
## See it work
|
|
@@ -139,7 +141,7 @@ Abbreviated shape — optional OCR skips gracefully when Tesseract is not instal
|
|
|
139
141
|
| --- | --- |
|
|
140
142
|
| `read_image` | Read a local image and return dimensions, mime, metadata, optional OCR, and trust warnings. |
|
|
141
143
|
|
|
142
|
-
Supported formats: PNG, JPEG, GIF, WebP, TIFF, and other formats sharp
|
|
144
|
+
Supported formats: PNG, JPEG, GIF, WebP, TIFF, and other formats the **Rust decode engine** supports (optional sharp covers additional formats when installed).
|
|
143
145
|
|
|
144
146
|
## Quick Start
|
|
145
147
|
|
package/bin/image-reader-mcp
CHANGED
|
@@ -1,25 +1,96 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
2
|
+
# Iris MCP launcher — Rust rmcp only (fail-closed).
|
|
3
|
+
# Resolves native via optionalDependencies, staged bin/native, or cargo target.
|
|
3
4
|
set -euo pipefail
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
PACKAGE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
7
|
+
|
|
8
|
+
host_platform_key() {
|
|
9
|
+
local os arch
|
|
10
|
+
os="$(uname -s 2>/dev/null || echo unknown)"
|
|
11
|
+
arch="$(uname -m 2>/dev/null || echo unknown)"
|
|
12
|
+
case "$os" in
|
|
13
|
+
Darwin) os="darwin" ;;
|
|
14
|
+
Linux) os="linux" ;;
|
|
15
|
+
*) os="$(printf '%s' "$os" | tr '[:upper:]' '[:lower:]')" ;;
|
|
16
|
+
esac
|
|
17
|
+
case "$arch" in
|
|
18
|
+
x86_64|amd64) arch="x64" ;;
|
|
19
|
+
aarch64|arm64) arch="arm64" ;;
|
|
20
|
+
esac
|
|
21
|
+
printf '%s-%s\n' "$os" "$arch"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
platform_package_name() {
|
|
25
|
+
case "$(host_platform_key)" in
|
|
26
|
+
darwin-arm64) printf '%s\n' '@sylphx/image-reader-mcp-darwin-arm64' ;;
|
|
27
|
+
darwin-x64) printf '%s\n' '@sylphx/image-reader-mcp-darwin-x64' ;;
|
|
28
|
+
linux-x64) printf '%s\n' '@sylphx/image-reader-mcp-linux-x64-gnu' ;;
|
|
29
|
+
linux-arm64) printf '%s\n' '@sylphx/image-reader-mcp-linux-arm64-gnu' ;;
|
|
30
|
+
*) return 1 ;;
|
|
31
|
+
esac
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
is_runnable_native() {
|
|
35
|
+
local candidate="$1"
|
|
36
|
+
[[ -n "$candidate" && -f "$candidate" && -x "$candidate" ]] || return 1
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
resolve_from_optional_dep() {
|
|
41
|
+
local pkg_name candidate
|
|
42
|
+
pkg_name="$(platform_package_name 2>/dev/null || true)"
|
|
43
|
+
[[ -n "$pkg_name" ]] || return 1
|
|
44
|
+
local search_roots=(
|
|
45
|
+
"$PACKAGE_ROOT/node_modules"
|
|
46
|
+
"$PACKAGE_ROOT/../node_modules"
|
|
47
|
+
"$PACKAGE_ROOT/../../node_modules"
|
|
48
|
+
)
|
|
49
|
+
for root in "${search_roots[@]}"; do
|
|
50
|
+
candidate="$root/$pkg_name/image-reader-mcp-server"
|
|
51
|
+
if is_runnable_native "$candidate"; then
|
|
52
|
+
printf '%s\n' "$candidate"
|
|
53
|
+
return 0
|
|
54
|
+
fi
|
|
55
|
+
done
|
|
56
|
+
if command -v node >/dev/null 2>&1; then
|
|
57
|
+
candidate="$(
|
|
58
|
+
node -e "
|
|
59
|
+
try {
|
|
60
|
+
const p = require('node:path');
|
|
61
|
+
const { createRequire } = require('node:module');
|
|
62
|
+
const r = createRequire(p.join(process.argv[1], 'package.json'));
|
|
63
|
+
const pkg = r.resolve(process.argv[2] + '/package.json');
|
|
64
|
+
process.stdout.write(p.join(p.dirname(pkg), 'image-reader-mcp-server'));
|
|
65
|
+
} catch { process.exit(1); }
|
|
66
|
+
" "$PACKAGE_ROOT" "$pkg_name" 2>/dev/null || true
|
|
67
|
+
)"
|
|
68
|
+
if is_runnable_native "${candidate:-}"; then
|
|
69
|
+
printf '%s\n' "$candidate"
|
|
70
|
+
return 0
|
|
71
|
+
fi
|
|
72
|
+
fi
|
|
73
|
+
return 1
|
|
74
|
+
}
|
|
6
75
|
|
|
7
76
|
resolve_rust_bin() {
|
|
8
77
|
if [[ -n "${IMAGE_READER_MCP_RUST_BIN:-}" && -x "${IMAGE_READER_MCP_RUST_BIN}" ]]; then
|
|
9
78
|
printf '%s\n' "${IMAGE_READER_MCP_RUST_BIN}"
|
|
10
79
|
return 0
|
|
11
80
|
fi
|
|
12
|
-
|
|
81
|
+
if bin="$(resolve_from_optional_dep)"; then
|
|
82
|
+
printf '%s\n' "$bin"
|
|
83
|
+
return 0
|
|
84
|
+
fi
|
|
13
85
|
for candidate in \
|
|
14
|
-
"$
|
|
15
|
-
"$
|
|
16
|
-
"$
|
|
17
|
-
if
|
|
86
|
+
"$PACKAGE_ROOT/bin/native/image-reader-mcp-server" \
|
|
87
|
+
"$PACKAGE_ROOT/target/release/image-reader-mcp-server" \
|
|
88
|
+
"$PACKAGE_ROOT/target/debug/image-reader-mcp-server"; do
|
|
89
|
+
if is_runnable_native "$candidate"; then
|
|
18
90
|
printf '%s\n' "$candidate"
|
|
19
91
|
return 0
|
|
20
92
|
fi
|
|
21
93
|
done
|
|
22
|
-
|
|
23
94
|
return 1
|
|
24
95
|
}
|
|
25
96
|
|
|
@@ -44,5 +115,5 @@ if bin="$(resolve_rust_bin)"; then
|
|
|
44
115
|
exec "$bin" "$@"
|
|
45
116
|
fi
|
|
46
117
|
|
|
47
|
-
echo "[
|
|
118
|
+
echo "[iris] Rust MCP server not found. Install matching optional native package (@sylphx/image-reader-mcp-<platform>) or run: bun run build:rust" >&2
|
|
48
119
|
exit 1
|
package/dist/doctor-cli.js
CHANGED
|
@@ -118,15 +118,15 @@ var require_filesystem = __commonJS((exports, module) => {
|
|
|
118
118
|
var LDD_PATH = "/usr/bin/ldd";
|
|
119
119
|
var SELF_PATH = "/proc/self/exe";
|
|
120
120
|
var MAX_LENGTH = 2048;
|
|
121
|
-
var readFileSync = (
|
|
122
|
-
const fd = fs.openSync(
|
|
121
|
+
var readFileSync = (path2) => {
|
|
122
|
+
const fd = fs.openSync(path2, "r");
|
|
123
123
|
const buffer = Buffer.alloc(MAX_LENGTH);
|
|
124
124
|
const bytesRead = fs.readSync(fd, buffer, 0, MAX_LENGTH, 0);
|
|
125
125
|
fs.close(fd, () => {});
|
|
126
126
|
return buffer.subarray(0, bytesRead);
|
|
127
127
|
};
|
|
128
|
-
var readFile = (
|
|
129
|
-
fs.open(
|
|
128
|
+
var readFile = (path2) => new Promise((resolve, reject) => {
|
|
129
|
+
fs.open(path2, "r", (err, fd) => {
|
|
130
130
|
if (err) {
|
|
131
131
|
reject(err);
|
|
132
132
|
} else {
|
|
@@ -238,11 +238,11 @@ var require_detect_libc = __commonJS((exports, module) => {
|
|
|
238
238
|
}
|
|
239
239
|
return null;
|
|
240
240
|
};
|
|
241
|
-
var familyFromInterpreterPath = (
|
|
242
|
-
if (
|
|
243
|
-
if (
|
|
241
|
+
var familyFromInterpreterPath = (path2) => {
|
|
242
|
+
if (path2) {
|
|
243
|
+
if (path2.includes("/ld-musl-")) {
|
|
244
244
|
return MUSL;
|
|
245
|
-
} else if (
|
|
245
|
+
} else if (path2.includes("/ld-linux-")) {
|
|
246
246
|
return GLIBC;
|
|
247
247
|
}
|
|
248
248
|
}
|
|
@@ -287,8 +287,8 @@ var require_detect_libc = __commonJS((exports, module) => {
|
|
|
287
287
|
cachedFamilyInterpreter = null;
|
|
288
288
|
try {
|
|
289
289
|
const selfContent = await readFile(SELF_PATH);
|
|
290
|
-
const
|
|
291
|
-
cachedFamilyInterpreter = familyFromInterpreterPath(
|
|
290
|
+
const path2 = interpreterPath(selfContent);
|
|
291
|
+
cachedFamilyInterpreter = familyFromInterpreterPath(path2);
|
|
292
292
|
} catch (e) {}
|
|
293
293
|
return cachedFamilyInterpreter;
|
|
294
294
|
};
|
|
@@ -299,8 +299,8 @@ var require_detect_libc = __commonJS((exports, module) => {
|
|
|
299
299
|
cachedFamilyInterpreter = null;
|
|
300
300
|
try {
|
|
301
301
|
const selfContent = readFileSync(SELF_PATH);
|
|
302
|
-
const
|
|
303
|
-
cachedFamilyInterpreter = familyFromInterpreterPath(
|
|
302
|
+
const path2 = interpreterPath(selfContent);
|
|
303
|
+
cachedFamilyInterpreter = familyFromInterpreterPath(path2);
|
|
304
304
|
} catch (e) {}
|
|
305
305
|
return cachedFamilyInterpreter;
|
|
306
306
|
};
|
|
@@ -1777,7 +1777,7 @@ var require_libvips = __commonJS((exports, module) => {
|
|
|
1777
1777
|
Copyright 2013 Lovell Fuller and others.
|
|
1778
1778
|
SPDX-License-Identifier: Apache-2.0
|
|
1779
1779
|
*/
|
|
1780
|
-
var { spawnSync } = __require("node:child_process");
|
|
1780
|
+
var { spawnSync: spawnSync2 } = __require("node:child_process");
|
|
1781
1781
|
var { createHash } = __require("node:crypto");
|
|
1782
1782
|
var semverCoerce = require_coerce();
|
|
1783
1783
|
var semverGreaterThanOrEqualTo = require_gte();
|
|
@@ -1861,7 +1861,7 @@ var require_libvips = __commonJS((exports, module) => {
|
|
|
1861
1861
|
};
|
|
1862
1862
|
var isRosetta = () => {
|
|
1863
1863
|
if (process.platform === "darwin" && process.arch === "x64") {
|
|
1864
|
-
const translated =
|
|
1864
|
+
const translated = spawnSync2("sysctl sysctl.proc_translated", spawnSyncOptions).stdout;
|
|
1865
1865
|
return (translated || "").trim() === "sysctl.proc_translated: 1";
|
|
1866
1866
|
}
|
|
1867
1867
|
return false;
|
|
@@ -1877,13 +1877,13 @@ var require_libvips = __commonJS((exports, module) => {
|
|
|
1877
1877
|
} catch {}
|
|
1878
1878
|
return "";
|
|
1879
1879
|
};
|
|
1880
|
-
var spawnRebuild = () =>
|
|
1880
|
+
var spawnRebuild = () => spawnSync2(`node-gyp rebuild --directory=src ${isEmscripten() ? "--nodedir=emscripten" : ""}`, {
|
|
1881
1881
|
...spawnSyncOptions,
|
|
1882
1882
|
stdio: "inherit"
|
|
1883
1883
|
}).status;
|
|
1884
1884
|
var globalLibvipsVersion = () => {
|
|
1885
1885
|
if (process.platform !== "win32") {
|
|
1886
|
-
const globalLibvipsVersion2 =
|
|
1886
|
+
const globalLibvipsVersion2 = spawnSync2("pkg-config --modversion vips-cpp", {
|
|
1887
1887
|
...spawnSyncOptions,
|
|
1888
1888
|
env: {
|
|
1889
1889
|
...process.env,
|
|
@@ -1897,7 +1897,7 @@ var require_libvips = __commonJS((exports, module) => {
|
|
|
1897
1897
|
};
|
|
1898
1898
|
var pkgConfigPath = () => {
|
|
1899
1899
|
if (process.platform !== "win32") {
|
|
1900
|
-
const brewPkgConfigPath =
|
|
1900
|
+
const brewPkgConfigPath = spawnSync2('which brew >/dev/null 2>&1 && brew environment --plain | grep PKG_CONFIG_LIBDIR | cut -d" " -f2', spawnSyncOptions).stdout || "";
|
|
1901
1901
|
return [
|
|
1902
1902
|
brewPkgConfigPath.trim(),
|
|
1903
1903
|
process.env.PKG_CONFIG_PATH,
|
|
@@ -1962,18 +1962,18 @@ var require_sharp = __commonJS((exports, module) => {
|
|
|
1962
1962
|
`@img/sharp-${runtimePlatform}/sharp.node`,
|
|
1963
1963
|
"@img/sharp-wasm32/sharp.node"
|
|
1964
1964
|
];
|
|
1965
|
-
var
|
|
1965
|
+
var path2;
|
|
1966
1966
|
var sharp;
|
|
1967
1967
|
var errors = [];
|
|
1968
|
-
for (
|
|
1968
|
+
for (path2 of paths) {
|
|
1969
1969
|
try {
|
|
1970
|
-
sharp = __require(
|
|
1970
|
+
sharp = __require(path2);
|
|
1971
1971
|
break;
|
|
1972
1972
|
} catch (err) {
|
|
1973
1973
|
errors.push(err);
|
|
1974
1974
|
}
|
|
1975
1975
|
}
|
|
1976
|
-
if (sharp &&
|
|
1976
|
+
if (sharp && path2.startsWith("@img/sharp-linux-x64") && !sharp._isUsingX64V2()) {
|
|
1977
1977
|
const err = new Error("Prebuilt binaries for linux-x64 require v2 microarchitecture");
|
|
1978
1978
|
err.code = "Unsupported CPU";
|
|
1979
1979
|
errors.push(err);
|
|
@@ -4835,15 +4835,15 @@ var require_color = __commonJS((exports, module) => {
|
|
|
4835
4835
|
};
|
|
4836
4836
|
}
|
|
4837
4837
|
function wrapConversion(toModel, graph) {
|
|
4838
|
-
const
|
|
4838
|
+
const path2 = [graph[toModel].parent, toModel];
|
|
4839
4839
|
let fn = conversions_default[graph[toModel].parent][toModel];
|
|
4840
4840
|
let cur = graph[toModel].parent;
|
|
4841
4841
|
while (graph[cur].parent) {
|
|
4842
|
-
|
|
4842
|
+
path2.unshift(graph[cur].parent);
|
|
4843
4843
|
fn = link(conversions_default[graph[cur].parent][cur], fn);
|
|
4844
4844
|
cur = graph[cur].parent;
|
|
4845
4845
|
}
|
|
4846
|
-
fn.conversion =
|
|
4846
|
+
fn.conversion = path2;
|
|
4847
4847
|
return fn;
|
|
4848
4848
|
}
|
|
4849
4849
|
function route(fromModel) {
|
|
@@ -5448,7 +5448,7 @@ var require_output = __commonJS((exports, module) => {
|
|
|
5448
5448
|
Copyright 2013 Lovell Fuller and others.
|
|
5449
5449
|
SPDX-License-Identifier: Apache-2.0
|
|
5450
5450
|
*/
|
|
5451
|
-
var
|
|
5451
|
+
var path2 = __require("node:path");
|
|
5452
5452
|
var is = require_is();
|
|
5453
5453
|
var sharp = require_sharp();
|
|
5454
5454
|
var formats = new Map([
|
|
@@ -5479,9 +5479,9 @@ var require_output = __commonJS((exports, module) => {
|
|
|
5479
5479
|
let err;
|
|
5480
5480
|
if (!is.string(fileOut)) {
|
|
5481
5481
|
err = new Error("Missing output file path");
|
|
5482
|
-
} else if (is.string(this.options.input.file) &&
|
|
5482
|
+
} else if (is.string(this.options.input.file) && path2.resolve(this.options.input.file) === path2.resolve(fileOut)) {
|
|
5483
5483
|
err = new Error("Cannot use same file for input and output");
|
|
5484
|
-
} else if (jp2Regex.test(
|
|
5484
|
+
} else if (jp2Regex.test(path2.extname(fileOut)) && !this.constructor.format.jp2k.output.file) {
|
|
5485
5485
|
err = errJp2Save();
|
|
5486
5486
|
}
|
|
5487
5487
|
if (err) {
|
|
@@ -6453,7 +6453,6 @@ var require_lib = __commonJS((exports, module) => {
|
|
|
6453
6453
|
import { createRequire as createRequire2 } from "node:module";
|
|
6454
6454
|
|
|
6455
6455
|
// src/doctor.ts
|
|
6456
|
-
var import_sharp = __toESM(require_lib(), 1);
|
|
6457
6456
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
6458
6457
|
import { existsSync as existsSync2 } from "node:fs";
|
|
6459
6458
|
import path2 from "node:path";
|
|
@@ -6501,9 +6500,11 @@ var listTesseractLanguages = () => {
|
|
|
6501
6500
|
}
|
|
6502
6501
|
const result = spawnSync("tesseract", ["--list-langs"], {
|
|
6503
6502
|
encoding: "utf8",
|
|
6504
|
-
timeout:
|
|
6503
|
+
timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
|
|
6505
6504
|
windowsHide: true
|
|
6506
6505
|
});
|
|
6506
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
6507
|
+
const languages = stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available"));
|
|
6507
6508
|
if (result.status !== 0) {
|
|
6508
6509
|
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
6509
6510
|
return {
|
|
@@ -6512,9 +6513,6 @@ var listTesseractLanguages = () => {
|
|
|
6512
6513
|
warning: stderr || "tesseract --list-langs failed"
|
|
6513
6514
|
};
|
|
6514
6515
|
}
|
|
6515
|
-
const out = `${result.stdout ?? ""}
|
|
6516
|
-
${result.stderr ?? ""}`;
|
|
6517
|
-
const languages = out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available languages") && !l.startsWith("Error"));
|
|
6518
6516
|
return { available: true, languages };
|
|
6519
6517
|
};
|
|
6520
6518
|
|
|
@@ -6550,7 +6548,18 @@ var probeTesseract = () => {
|
|
|
6550
6548
|
};
|
|
6551
6549
|
var probeSharp = async () => {
|
|
6552
6550
|
try {
|
|
6553
|
-
|
|
6551
|
+
let sharpMod;
|
|
6552
|
+
try {
|
|
6553
|
+
sharpMod = (await Promise.resolve().then(() => __toESM(require_lib(), 1))).default ?? await Promise.resolve().then(() => __toESM(require_lib(), 1));
|
|
6554
|
+
} catch {
|
|
6555
|
+
return {
|
|
6556
|
+
id: "sharp",
|
|
6557
|
+
status: "warn",
|
|
6558
|
+
message: "sharp not installed (optional). Rust decode path is preferred for MCP."
|
|
6559
|
+
};
|
|
6560
|
+
}
|
|
6561
|
+
const sharp = sharpMod;
|
|
6562
|
+
const buffer = await sharp({
|
|
6554
6563
|
create: {
|
|
6555
6564
|
width: 1,
|
|
6556
6565
|
height: 1,
|
|
@@ -6568,7 +6577,7 @@ var probeSharp = async () => {
|
|
|
6568
6577
|
return {
|
|
6569
6578
|
id: "sharp",
|
|
6570
6579
|
status: "ok",
|
|
6571
|
-
message: `sharp decode pipeline is available (v${
|
|
6580
|
+
message: `sharp decode pipeline is available (v${sharp.versions?.sharp ?? "unknown"}).`
|
|
6572
6581
|
};
|
|
6573
6582
|
} catch (error) {
|
|
6574
6583
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -6643,7 +6652,7 @@ var probeRustDecodeFlag = () => {
|
|
|
6643
6652
|
return {
|
|
6644
6653
|
id: "rust_decode_flag",
|
|
6645
6654
|
status: "warn",
|
|
6646
|
-
message: "Rust decode CLI is not built.
|
|
6655
|
+
message: "Rust decode CLI is not built. Install/stage image-reader-cli for native decode, or optional `sharp` for TS fallback."
|
|
6647
6656
|
};
|
|
6648
6657
|
};
|
|
6649
6658
|
var probeNode = () => {
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/image-reader-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"mcpName": "io.github.SylphxAI/image-reader-mcp",
|
|
5
|
-
"description": "Iris
|
|
5
|
+
"description": "Iris — Evidence-first image reading for AI agents — metadata, OCR text, regions, and citeable evidence without generative LLM.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"image-reader-mcp": "./bin/image-reader-mcp",
|
|
9
9
|
"iris": "./bin/image-reader-mcp"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"bin/",
|
|
12
|
+
"bin/image-reader-mcp",
|
|
13
13
|
"dist/",
|
|
14
14
|
"README.md",
|
|
15
15
|
"LICENSE",
|
|
@@ -69,8 +69,6 @@
|
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
71
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
72
|
-
"exifr": "^7.1.3",
|
|
73
|
-
"sharp": "^0.34.3",
|
|
74
72
|
"zod": "^4.4.3"
|
|
75
73
|
},
|
|
76
74
|
"devDependencies": {
|
|
@@ -82,5 +80,13 @@
|
|
|
82
80
|
"typescript": "^7.0.2"
|
|
83
81
|
},
|
|
84
82
|
"packageManager": "bun@1.3.12",
|
|
85
|
-
"private": false
|
|
83
|
+
"private": false,
|
|
84
|
+
"optionalDependencies": {
|
|
85
|
+
"sharp": "^0.34.3",
|
|
86
|
+
"exifr": "^7.1.3",
|
|
87
|
+
"@sylphx/image-reader-mcp-darwin-arm64": "0.1.5",
|
|
88
|
+
"@sylphx/image-reader-mcp-darwin-x64": "0.1.5",
|
|
89
|
+
"@sylphx/image-reader-mcp-linux-x64-gnu": "0.1.5",
|
|
90
|
+
"@sylphx/image-reader-mcp-linux-arm64-gnu": "0.1.5"
|
|
91
|
+
}
|
|
86
92
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import sharp from 'sharp';
|
|
6
5
|
import { resolveRustCliBinary } from './engine/rust-decode.js';
|
|
7
6
|
import { listTesseractLanguages } from './utils/ocr.js';
|
|
8
7
|
import { IMAGE_SAFETY_LIMITS } from './utils/safety.js';
|
|
@@ -53,6 +52,26 @@ const probeTesseract = (): DoctorCheck => {
|
|
|
53
52
|
|
|
54
53
|
const probeSharp = async (): Promise<DoctorCheck> => {
|
|
55
54
|
try {
|
|
55
|
+
let sharpMod: unknown;
|
|
56
|
+
try {
|
|
57
|
+
sharpMod =
|
|
58
|
+
((await import('sharp')) as { default?: unknown }).default ?? (await import('sharp'));
|
|
59
|
+
} catch {
|
|
60
|
+
return {
|
|
61
|
+
id: 'sharp',
|
|
62
|
+
status: 'warn',
|
|
63
|
+
message: 'sharp not installed (optional). Rust decode path is preferred for MCP.',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const sharp = sharpMod as {
|
|
67
|
+
(
|
|
68
|
+
input: unknown,
|
|
69
|
+
opts?: unknown
|
|
70
|
+
): {
|
|
71
|
+
png: () => { toBuffer: () => Promise<Buffer> };
|
|
72
|
+
};
|
|
73
|
+
versions?: { sharp?: string };
|
|
74
|
+
};
|
|
56
75
|
const buffer = await sharp({
|
|
57
76
|
create: {
|
|
58
77
|
width: 1,
|
|
@@ -75,7 +94,7 @@ const probeSharp = async (): Promise<DoctorCheck> => {
|
|
|
75
94
|
return {
|
|
76
95
|
id: 'sharp',
|
|
77
96
|
status: 'ok',
|
|
78
|
-
message: `sharp decode pipeline is available (v${sharp.versions
|
|
97
|
+
message: `sharp decode pipeline is available (v${sharp.versions?.sharp ?? 'unknown'}).`,
|
|
79
98
|
};
|
|
80
99
|
} catch (error: unknown) {
|
|
81
100
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -162,7 +181,7 @@ const probeRustDecodeFlag = (): DoctorCheck => {
|
|
|
162
181
|
id: 'rust_decode_flag',
|
|
163
182
|
status: 'warn',
|
|
164
183
|
message:
|
|
165
|
-
'Rust decode CLI is not built.
|
|
184
|
+
'Rust decode CLI is not built. Install/stage image-reader-cli for native decode, or optional `sharp` for TS fallback.',
|
|
166
185
|
};
|
|
167
186
|
};
|
|
168
187
|
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import fs, { stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import exifr from 'exifr';
|
|
4
|
-
import sharp from 'sharp';
|
|
5
3
|
import {
|
|
6
4
|
cropRegionViaRustEngine,
|
|
7
5
|
probeImageViaRustEngine,
|
|
@@ -46,6 +44,23 @@ const readMetadata = async (
|
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
try {
|
|
47
|
+
let exifr: {
|
|
48
|
+
parse: (
|
|
49
|
+
path: string,
|
|
50
|
+
opts: Record<string, unknown>
|
|
51
|
+
) => Promise<Record<string, unknown> | null>;
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
exifr =
|
|
55
|
+
((await import('exifr')) as { default?: typeof exifr }).default ??
|
|
56
|
+
((await import('exifr')) as unknown as typeof exifr);
|
|
57
|
+
} catch {
|
|
58
|
+
return {
|
|
59
|
+
trustWarnings: [
|
|
60
|
+
'Optional dependency `exifr` is not installed; EXIF/XMP/IPTC metadata skipped (geometry/OCR still available).',
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
49
64
|
const parsed = await exifr.parse(filePath, {
|
|
50
65
|
tiff: true,
|
|
51
66
|
xmp: true,
|
|
@@ -140,6 +155,28 @@ export const readImage = tool()
|
|
|
140
155
|
twin.metadata = extractedMetadata;
|
|
141
156
|
}
|
|
142
157
|
} else {
|
|
158
|
+
type SharpMeta = {
|
|
159
|
+
width?: number;
|
|
160
|
+
height?: number;
|
|
161
|
+
format?: string;
|
|
162
|
+
orientation?: number;
|
|
163
|
+
space?: string;
|
|
164
|
+
hasAlpha?: boolean;
|
|
165
|
+
};
|
|
166
|
+
type SharpFn = (
|
|
167
|
+
path: string,
|
|
168
|
+
opts?: { failOn?: string }
|
|
169
|
+
) => { metadata: () => Promise<SharpMeta> };
|
|
170
|
+
let sharp: SharpFn;
|
|
171
|
+
try {
|
|
172
|
+
const mod = await import('sharp');
|
|
173
|
+
sharp = (mod as { default?: SharpFn }).default ?? (mod as unknown as SharpFn);
|
|
174
|
+
} catch {
|
|
175
|
+
throw new ImageError(
|
|
176
|
+
ErrorCode.InvalidRequest,
|
|
177
|
+
'Rust decode engine is unavailable and optional `sharp` is not installed. Build/stage image-reader-cli or install sharp for the TS fallback.'
|
|
178
|
+
);
|
|
179
|
+
}
|
|
143
180
|
const image = sharp(resolvedPath, { failOn: 'none' });
|
|
144
181
|
const metadata = await image.metadata();
|
|
145
182
|
validateImageSafety({
|
|
@@ -202,6 +239,7 @@ export const readImage = tool()
|
|
|
202
239
|
? { languages_warning: ocr.languages_warning }
|
|
203
240
|
: {}),
|
|
204
241
|
...(ocr.words !== undefined ? { words: ocr.words } : {}),
|
|
242
|
+
...(ocr.native_blocks !== undefined ? { native_blocks: ocr.native_blocks } : {}),
|
|
205
243
|
};
|
|
206
244
|
}
|
|
207
245
|
|
package/src/schemas/readImage.ts
CHANGED
|
@@ -74,12 +74,14 @@ export const readImageArgsSchema = z.object({
|
|
|
74
74
|
include_palette: z
|
|
75
75
|
.boolean()
|
|
76
76
|
.optional()
|
|
77
|
-
.describe(
|
|
77
|
+
.describe(
|
|
78
|
+
'Sample an approximate local color palette via optional sharp when installed (not ML). Defaults to false.'
|
|
79
|
+
),
|
|
78
80
|
include_optional_llm: z
|
|
79
81
|
.boolean()
|
|
80
82
|
.optional()
|
|
81
83
|
.describe(
|
|
82
|
-
'Optional caption via IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
|
|
84
|
+
'Optional local frontier caption via Ollama vision models or IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
|
|
83
85
|
),
|
|
84
86
|
});
|
|
85
87
|
|
|
@@ -110,6 +112,17 @@ export const agentMediaTwinSchema = z.object({
|
|
|
110
112
|
})
|
|
111
113
|
)
|
|
112
114
|
.optional(),
|
|
115
|
+
native_blocks: z
|
|
116
|
+
.array(
|
|
117
|
+
z.object({
|
|
118
|
+
id: z.string(),
|
|
119
|
+
kind: z.enum(['block', 'paragraph']),
|
|
120
|
+
text: z.string(),
|
|
121
|
+
bbox: boundingBoxSchema,
|
|
122
|
+
confidence: z.number().optional(),
|
|
123
|
+
})
|
|
124
|
+
)
|
|
125
|
+
.optional(),
|
|
113
126
|
})
|
|
114
127
|
.optional(),
|
|
115
128
|
region_evidence: z
|
|
@@ -157,6 +170,7 @@ export const agentMediaTwinSchema = z.object({
|
|
|
157
170
|
skipped_reason: z.string().optional(),
|
|
158
171
|
route: z.string().optional(),
|
|
159
172
|
caption: z.string().optional(),
|
|
173
|
+
model: z.string().optional(),
|
|
160
174
|
})
|
|
161
175
|
.optional(),
|
|
162
176
|
})
|
package/src/utils/agentMap.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentMediaTwin, ReadImageArgs } from '../schemas/readImage.js';
|
|
2
2
|
import { buildAgentImageMap } from './agentMap.js';
|
|
3
|
-
import {
|
|
3
|
+
import { buildBestEffortLayout } from './layout.js';
|
|
4
4
|
import { maybeOptionalImageCaption } from './optionalLlm.js';
|
|
5
5
|
import { samplePalette } from './palette.js';
|
|
6
6
|
|
|
@@ -18,14 +18,30 @@ export async function applyImageIntelligence(
|
|
|
18
18
|
|
|
19
19
|
const includeLayout = input.include_layout ?? includeOcr;
|
|
20
20
|
if (includeLayout && next.ocr?.lines && next.ocr.lines.length > 0) {
|
|
21
|
-
const
|
|
22
|
-
|
|
21
|
+
const ocrAny = next.ocr as {
|
|
22
|
+
lines: Array<{
|
|
23
|
+
text: string;
|
|
24
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
25
|
+
confidence?: number;
|
|
26
|
+
}>;
|
|
27
|
+
native_blocks?: Array<{
|
|
28
|
+
id: string;
|
|
29
|
+
kind: 'block' | 'paragraph';
|
|
30
|
+
text: string;
|
|
31
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
32
|
+
confidence?: number;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
const layout = buildBestEffortLayout({
|
|
36
|
+
lines: ocrAny.lines.map((line) => ({
|
|
23
37
|
text: line.text,
|
|
24
38
|
bbox: line.bbox,
|
|
25
39
|
...(line.confidence !== undefined ? { confidence: line.confidence } : {}),
|
|
26
|
-
}))
|
|
27
|
-
|
|
40
|
+
})),
|
|
41
|
+
...(ocrAny.native_blocks ? { nativeBlocks: ocrAny.native_blocks } : {}),
|
|
42
|
+
});
|
|
28
43
|
next.layout = layout;
|
|
44
|
+
next.trust_warnings.push(`layout_policy: ${layout.policy}`);
|
|
29
45
|
for (const warning of layout.warnings) {
|
|
30
46
|
next.trust_warnings.push(`layout: ${warning}`);
|
|
31
47
|
}
|