From 2b33d9f6f043c08d79d03ceabdd2a03af6fdfb28 Mon Sep 17 00:00:00 2001 From: RoyLin Date: Sun, 19 Jul 2026 05:29:13 +0800 Subject: [PATCH] feat(ocr): add built-in first-use capability --- .github/workflows/release.yml | 48 +- Cargo.lock | 20 + Cargo.toml | 7 +- README.md | 117 +++- .../skills/a3s-use-browser/SKILL.md | 5 +- crates/browser-driver/src/mcp.rs | 4 + crates/extension/src/lib.rs | 5 +- crates/ocr/Cargo.toml | 34 + crates/ocr/README.md | 34 + crates/ocr/skills/a3s-use-ocr/SKILL.md | 48 ++ crates/ocr/src/cli.rs | 205 ++++++ crates/ocr/src/client.rs | 649 ++++++++++++++++++ crates/ocr/src/lib.rs | 18 + crates/ocr/src/main.rs | 39 ++ crates/ocr/src/mcp.rs | 170 +++++ crates/ocr/src/models.rs | 105 +++ crates/ocr/src/provider.rs | 393 +++++++++++ crates/office/skills/a3s-use-office/SKILL.md | 25 +- .../skills/a3s-use-office/references/mcp.md | 15 +- docs/architecture.md | 43 +- src/capability_registry.rs | 154 ++++- src/cli.rs | 88 ++- src/cli_tests.rs | 50 +- src/lib.rs | 6 + src/mcp.rs | 131 +++- src/mcp/office.rs | 96 ++- src/mcp/office/tests.rs | 26 +- src/ocr_builtin.rs | 95 +++ tests/cli.rs | 77 ++- 29 files changed, 2608 insertions(+), 99 deletions(-) create mode 100644 crates/ocr/Cargo.toml create mode 100644 crates/ocr/README.md create mode 100644 crates/ocr/skills/a3s-use-ocr/SKILL.md create mode 100644 crates/ocr/src/cli.rs create mode 100644 crates/ocr/src/client.rs create mode 100644 crates/ocr/src/lib.rs create mode 100644 crates/ocr/src/main.rs create mode 100644 crates/ocr/src/mcp.rs create mode 100644 crates/ocr/src/models.rs create mode 100644 crates/ocr/src/provider.rs create mode 100644 src/ocr_builtin.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfee133e..0116089b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: test "${{ inputs.release_tag }}" = "v${version}" fi - run: cargo fmt --all -- --check - - run: cargo test --workspace --all-features --locked + - run: cargo test --workspace --all-features --locked -- --test-threads=1 - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings binaries: @@ -84,12 +84,13 @@ jobs: version="${{ needs.validate.outputs.version }}" archive="a3s-use-${version}-${{ matrix.name }}.tar.gz" stage="${RUNNER_TEMP}/a3s-use-${version}-${{ matrix.name }}" - install -d "${stage}/skills" "${stage}/skill-data" "${stage}/office-skills" "${stage}/dashboard" + install -d "${stage}/skills" "${stage}/skill-data" "${stage}/office-skills" "${stage}/ocr-skills" "${stage}/dashboard" install -m 0755 "target/${{ matrix.target }}/release/a3s-use" "${stage}/a3s-use" install -m 0755 "target/${{ matrix.target }}/release/a3s-use-browser-driver" "${stage}/a3s-use-browser-driver" cp -R crates/browser-driver/skills/. "${stage}/skills/" cp -R crates/browser-driver/skill-data/. "${stage}/skill-data/" cp -R crates/office/skills/. "${stage}/office-skills/" + cp -R crates/ocr/skills/. "${stage}/ocr-skills/" cp -R crates/browser-driver/dashboard/out/. "${stage}/dashboard/" install -m 0644 LICENSE README.md THIRD_PARTY_NOTICES.md "${stage}/" install -m 0644 crates/browser-driver/LICENSE-APACHE-2.0 "${stage}/LICENSE-APACHE-2.0" @@ -110,6 +111,7 @@ jobs: Copy-Item -Recurse "crates/browser-driver/skills" "$stage/skills" Copy-Item -Recurse "crates/browser-driver/skill-data" "$stage/skill-data" Copy-Item -Recurse "crates/office/skills" "$stage/office-skills" + Copy-Item -Recurse "crates/ocr/skills" "$stage/ocr-skills" Copy-Item -Recurse "crates/browser-driver/dashboard/out" "$stage/dashboard" Copy-Item LICENSE,README.md,THIRD_PARTY_NOTICES.md $stage Copy-Item crates/browser-driver/LICENSE-APACHE-2.0 "$stage/LICENSE-APACHE-2.0" @@ -128,6 +130,7 @@ jobs: test -x "${install_root}/a3s-use-browser-driver" test -f "${install_root}/skill-data/core/SKILL.md" test -f "${install_root}/office-skills/a3s-use-office/SKILL.md" + test -f "${install_root}/ocr-skills/a3s-use-ocr/SKILL.md" test -f "${install_root}/dashboard/index.html" test -f "${install_root}/LICENSE-APACHE-2.0" test -f "${install_root}/UPSTREAM.md" @@ -143,6 +146,29 @@ jobs: "agentcore", "core", "dogfood", "electron", "slack", "vercel-sandbox" } PY + "${install_root}/a3s-use" ocr doctor --json > "${RUNNER_TEMP}/ocr-doctor.json" + python3 - "${RUNNER_TEMP}/ocr-doctor.json" <<'PY' + import json, pathlib, sys + value = json.loads(pathlib.Path(sys.argv[1]).read_text()) + assert value["ok"] is True + assert value["data"]["readiness"] in {"ready", "missing", "broken", "unknown"} + PY + "${install_root}/a3s-use" capability snapshot --json > "${RUNNER_TEMP}/capabilities.json" + python3 - "${RUNNER_TEMP}/capabilities.json" "${install_root}" <<'PY' + import json, pathlib, sys + value = json.loads(pathlib.Path(sys.argv[1]).read_text()) + root = pathlib.Path(sys.argv[2]).resolve() + ocr = next( + capability + for capability in value["data"]["registry"]["capabilities"] + if capability["id"] == "use/ocr" + ) + assert ocr["mcp"]["target"] == "ocr-native" + assert len(ocr["skills"]) == 1 + assert pathlib.Path(ocr["skills"][0]["path"]).resolve() == ( + root / "ocr-skills" / "a3s-use-ocr" / "SKILL.md" + ) + PY A3S_OFFICECLI_EXECUTABLE="${install_root}/must-not-be-invoked" \ "${install_root}/a3s-use" office skills list --json > "${RUNNER_TEMP}/office-skills.json" python3 - "${RUNNER_TEMP}/office-skills.json" <<'PY' @@ -179,6 +205,7 @@ jobs: "$root/a3s-use-browser-driver.exe", "$root/skill-data/core/SKILL.md", "$root/office-skills/a3s-use-office/SKILL.md", + "$root/ocr-skills/a3s-use-ocr/SKILL.md", "$root/dashboard/index.html", "$root/LICENSE-APACHE-2.0", "$root/UPSTREAM.md", @@ -194,6 +221,19 @@ jobs: if (-not $officeSkills.ok -or $officeSkills.data.Count -ne 1 -or $officeSkills.data[0].name -ne "a3s-use-office") { throw "Packaged Office Skill smoke failed" } + $ocr = (& "$root/a3s-use.exe" ocr doctor --json | ConvertFrom-Json) + if (-not $ocr.ok -or -not $ocr.data.readiness) { throw "Built-in OCR doctor smoke failed" } + $capabilities = (& "$root/a3s-use.exe" capability snapshot --json | ConvertFrom-Json) + $ocrCapability = $capabilities.data.registry.capabilities | + Where-Object id -eq "use/ocr" + if ($ocrCapability.mcp.target -ne "ocr-native" -or $ocrCapability.skills.Count -ne 1) { + throw "Built-in OCR capability projection smoke failed" + } + $expectedOcrSkill = (Resolve-Path "$root/ocr-skills/a3s-use-ocr/SKILL.md").Path + $actualOcrSkill = (Resolve-Path $ocrCapability.skills[0].path).Path + if (-not [StringComparer]::OrdinalIgnoreCase.Equals($actualOcrSkill, $expectedOcrSkill)) { + throw "Built-in OCR Skill was not projected from the installed release" + } $requests = @( '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"release-smoke","version":"1"}}}', '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}', @@ -223,7 +263,7 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Publish Core then Browser + - name: Publish Core, OCR, then Browser env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_TOKEN }} VERSION: ${{ needs.validate.outputs.version }} @@ -262,6 +302,8 @@ jobs: publish_once a3s-use-core wait_until_visible a3s-use-core + publish_once a3s-use-ocr + wait_until_visible a3s-use-ocr publish_once a3s-use-browser release: diff --git a/Cargo.lock b/Cargo.lock index 14cb1eb3..80e0f846 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,7 @@ dependencies = [ "a3s-use-browser", "a3s-use-core", "a3s-use-extension", + "a3s-use-ocr", "a3s-use-office", "anyhow", "async-trait", @@ -115,6 +116,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "a3s-use-ocr" +version = "0.1.1" +dependencies = [ + "a3s-use-core", + "axum", + "base64", + "clap", + "reqwest", + "rmcp", + "schemars", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "url", +] + [[package]] name = "a3s-use-office" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index a136a13b..8c17bb9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/browser-driver", "crates/office", "crates/extension", + "crates/ocr", ] resolver = "2" @@ -48,7 +49,7 @@ license.workspace = true repository.workspace = true authors.workspace = true rust-version = "1.85" -description = "Typed Browser, Office, and external application capabilities for A3S" +description = "Typed Browser, Office, OCR, and external application capabilities for A3S" [lib] name = "a3s_use" @@ -59,7 +60,7 @@ name = "a3s-use" path = "src/main.rs" [features] -default = ["browser", "office", "extensions", "mcp"] +default = ["browser", "office", "ocr", "extensions", "mcp"] browser = ["dep:a3s-use-browser"] office = [ "dep:a3s-use-office", @@ -67,6 +68,7 @@ office = [ "dep:futures-util", "dep:getrandom", ] +ocr = ["dep:a3s-use-ocr"] extensions = ["dep:a3s-use-extension"] mcp = [ "dep:axum", @@ -84,6 +86,7 @@ lightpanda = ["browser", "a3s-use-browser/lightpanda"] a3s-use-core = { version = "0.1.1", path = "crates/core" } a3s-use-browser = { version = "0.1.1", path = "crates/browser", optional = true } a3s-use-office = { version = "0.1.1", path = "crates/office", optional = true } +a3s-use-ocr = { version = "0.1.1", path = "crates/ocr", optional = true } a3s-use-extension = { version = "0.1.1", path = "crates/extension", optional = true } anyhow.workspace = true axum = { workspace = true, optional = true } diff --git a/README.md b/README.md index 9b8376a1..5b4916fa 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

- Use browsers, Office documents, and independently shipped application domains through native CLI, standard MCP, and Skills + Use browsers, Office documents, OCR, and independently shipped application domains through native CLI, standard MCP, and Skills

@@ -14,6 +14,7 @@ Quick Start • Browser • Office • + OCR • Extensions • Architecture • Development @@ -23,10 +24,10 @@ ## Overview -**A3S Use** is the application-capability layer for A3S. Browser and Office are -first-party domains in the default distribution. Independently distributed -packages can add more domains without rebuilding Use by declaring native CLI, -standard MCP, and/or `SKILL.md` surfaces in an A3S ACL manifest. +**A3S Use** is the application-capability layer for A3S. Browser, native Office, +and OCR are first-party domains in the default distribution. Independently +distributed packages can add more domains without rebuilding Use by declaring +native CLI, standard MCP, and/or `SKILL.md` surfaces in an A3S ACL manifest. The primary user entry point is `a3s use`; `a3s-use` is the standalone binary used by the umbrella CLI and remains available for direct use, automation, and @@ -95,7 +96,14 @@ a3s use mcp serve browser a3s use mcp serve office-native # Keep using the pinned OfficeCLI compatibility MCP server where needed. +a3s use mcp serve office-compat +# Legacy alias: a3s use mcp serve office + +# Built-in OCR; provider readiness remains explicit. +a3s use ocr doctor --json +a3s use ocr extract ./scan.png --language eng --json +a3s use mcp serve ocr ``` Every domain argument accepted by `a3s use ...` can also be passed directly to @@ -103,8 +111,8 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to ## Features -- **Built-In Browser and Office**: Keep stable first-party command routes while - reporting provider readiness separately +- **Built-In Browser, Office, and OCR**: Keep stable first-party command routes + while reporting provider readiness separately - **Typed Rust Contracts**: Embed Browser rendering and Office operations without starting a CLI process or an MCP server - **Agent Browser Compatibility**: Provide the locked 82-command vocabulary, @@ -129,6 +137,9 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to safe Word, Spreadsheet, Presentation, native MCP, and compatibility workflows - **External Domains**: Install process-isolated packages that expose any useful combination of CLI, MCP, and Skill surfaces +- **First-Party OCR Domain**: Extract text and bounded layout evidence with + a local Tesseract provider or an explicitly configured vision endpoint, + without silently installing a provider or hiding remote image transfer - **Hot-Plug Discovery**: Publish immutable generation/revision snapshots so a resident host can add, replace, or remove live capabilities without restarting - **Content-Bound Skills**: Project an absolute package path and lowercase @@ -149,6 +160,7 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to | Browser | Built in | Full Browser vocabulary | A3S Use standard MCP server | Six packaged Browser Skills | A3S Use | | Office | Built in | Stable Office vocabulary | Typed native preview plus OfficeCLI compatibility server | Packaged `a3s-use-office` Skill | A3S Use native engine; OfficeCLI compatibility in 0.1.x | | Box | Reserved built-in route | Native A3S Box vocabulary | — | — | Umbrella A3S CLI | +| OCR | Built in | Doctor and typed image extraction | `ocr_doctor` and `ocr_extract` | One provider-safe OCR Skill | A3S Use process and explicitly configured provider | | External domain | Installed extension | Optional native executable | Optional standard MCP server | Optional `SKILL.md` | Extension package plus A3S Use lifecycle | The Box route is component-backed. The umbrella CLI resolves its authoritative @@ -157,12 +169,13 @@ does not copy Box, discover a replacement on `PATH`, or write a second receipt. ### Cargo feature matrix -Default features are `browser`, `office`, `extensions`, and `mcp`. +Default features are `browser`, `office`, `ocr`, `extensions`, and `mcp`. | Feature | Included capability | | --- | --- | | `browser` | Typed Browser library, stateless rendering, and full Browser driver delegation | | `office` | Typed Office contracts, native OOXML read engine, and temporary OfficeCLI compatibility | +| `ocr` | Built-in typed OCR CLI/MCP with local Tesseract and explicit vision providers | | `extensions` | ACL manifests, package receipts, hot-plug registry, and external CLI/MCP/Skill routes | | `mcp` | Standard MCP servers plus the managed Browser Streamable HTTP lifecycle | | `lightpanda` | Explicit opt-in Lightpanda provider support in addition to Chrome | @@ -179,6 +192,7 @@ A compiled command surface is not proof that its provider is installed. Use | `a3s-use-browser-driver` | Complete interactive Browser CLI, MCP tools, Skills, Dashboard, and compatibility runtime | | `a3s-use-office` | Native OOXML foundation, typed Office operations, and compatibility lifecycle | | `a3s-use-extension` | A3S ACL manifest model, package registry, leases, and native surface descriptors | +| `a3s-use-ocr` | Typed local/vision OCR providers, CLI, MCP tools, and release-packaged Skill assets | | `a3s-use` | Facade library, standalone CLI host, capability projection, and MCP entry points | ## Quick Start @@ -198,9 +212,9 @@ a3s use doctor --json Prebuilt archives are also published on [GitHub Releases](https://github.com/A3S-Lab/Use/releases). A complete archive contains `a3s-use`, its sibling `a3s-use-browser-driver`, Browser Skills, the -first-party Office Skill, the Dashboard, and license/provenance notices. Keep -those packaged assets together; installing only the facade binary does not -provide the complete Browser and Office Skill surfaces. +first-party Office and OCR Skills, the Dashboard, and license/provenance +notices. Keep those packaged assets together; installing only the facade binary +does not provide the complete Browser, Office, and OCR Skill surfaces. Build all binaries from source with: @@ -515,7 +529,10 @@ agents without starting OfficeCLI. Discover its metadata with `office skills get a3s-use-office`, append its four format/MCP references with `--full`, or locate the installed directory with `office skills path`. The capability snapshot binds the Skill path and lowercase SHA-256 so a resident -host can verify the bytes before loading them. +host can verify the bytes before loading them. Resident Code hosts receive the +native engine as canonical route `use/office` targeting `office-native`; a ready +OfficeCLI installation is projected separately as `use/office-compat` targeting +`office-compat`. Other `0.1.x` commands and the default `mcp serve office` target still use a compatibility backend pinned to OfficeCLI `1.0.136`. This is a migration boundary, not a native-promotion claim. The default routes will be promoted @@ -1586,6 +1603,34 @@ compatibility response can return See [Native Office Engine](docs/native-office.md) for the complete requirements, compatibility scope, safety invariants, delivery gates, and migration plan. +## OCR + +`a3s-use-ocr` implements the reserved built-in `ocr` route. The default Use +release packages its `a3s-use-ocr` Skill and exposes `ocr_doctor` plus +`ocr_extract` over standard MCP, so a resident A3S Code session receives +`mcp__use_ocr__*` without installing a separate extension. + +OCR never installs a provider silently. `auto` prefers an explicitly configured +or discoverable Tesseract executable. Vision OCR is enabled only when its model +and endpoint configuration are present; non-loopback endpoints require HTTPS +and an API key, and the diagnostic discloses that the complete source image +leaves the device. Supported inputs are bounded local PNG, JPEG, WebP, GIF, +BMP, and TIFF files. The result binds the canonical source path, media type, +byte length, and SHA-256 alongside text and any available +confidence/bounding-box evidence. + +```bash +a3s use ocr doctor --json +a3s use ocr extract ./scan.png --language eng --json +a3s use mcp serve ocr +``` + +A3S Code may first-use install the verified parent Use release. OCR provider +selection remains explicit, and remote vision extraction still escalates to +the parent TUI before source bytes leave the device. + +See the [OCR crate](crates/ocr/README.md) for configuration and provider +boundaries. ## External Extensions External Use domains stay behind process boundaries. A package contains an @@ -1636,15 +1681,17 @@ roadmap work; Use does not silently install arbitrary Homebrew, npm, Cargo, system, or `PATH` packages. Built-in and management routes are reserved. Extensions cannot shadow -`browser`, `office`, `box`, `component`, `capability`, or other host commands. +`browser`, `office`, `ocr`, `box`, `component`, `capability`, or other host +commands. ## Live Host Integration Resident hosts consume `capability snapshot` and `capability watch`. The -projection presents Browser, Office, Box, and enabled extensions through one -read-only schema while preserving each binding's `built-in` or `extension` -origin. The extension generation advances on receipt mutations; a content -revision also changes when built-in readiness or packaged Skill content changes. +projection presents Browser, native Office, OCR, Box, and enabled extensions +through one read-only schema while preserving each binding's `built-in` or +`extension` origin. The extension generation advances on receipt mutations; a +content revision also changes when built-in readiness or packaged Skill content +changes. ```bash a3s-use capability snapshot --json @@ -1662,10 +1709,18 @@ tools. Projected Skills provide guidance only and cannot expand permissions or authorize installation. Code verifies their projected SHA-256 before loading the exact bytes. +The built-in Office projection is intentionally host-oriented: `use/office` +always exposes the in-process native MCP target when MCP support is compiled, +without consulting OfficeCLI. A discovered OfficeCLI provider is a separate +optional `use/office-compat` route, so native readiness and compatibility +installation cannot mask or replace each other. + A capability becomes callable only after its MCP connection is ready. A removed or replaced route leaves the worker catalog before its old connection -drains. Starting Code never installs Use: component installation remains an -explicit umbrella CLI action. +drains. Code TUI resolves the catalogued Use component on first launch and may +install its verified release before terminal takeover. Offline mode and +`A3S_NO_AUTO_INSTALL=1` remain strict no-mutation boundaries; setup failure is +non-fatal and stays visible through `/use`. ## Protocol and Lifecycle Boundaries @@ -1702,13 +1757,13 @@ crash, and in-flight calls retain the exact package generation they accepted. a3s use │ a3s-use host - ┌─────────────┼──────────────┐ - │ │ │ - Browser Office extension registry - typed + driver native OOXML CLI / MCP / Skill - + 0.1 compat - │ │ │ - └──────── capability snapshot/watch ───────► A3S Code + ┌──────────┬──────────┬──────────┬──────────────┐ + │ │ │ │ │ + Browser Office OCR extension registry + typed + driver OOXML local/vision CLI / MCP / Skill + + 0.1 compat + │ │ │ │ + └──────── capability snapshot/watch ───────────► A3S Code a3s-search ── Arc ──► a3s-use-browser @@ -1717,10 +1772,12 @@ crash, and in-flight calls retain the exact package generation they accepted. The dependency arrows are intentional. Search links only the Browser contract, so rendering does not require `a3s-use`, MCP, or a resident process. Office is -an in-process typed engine with a temporary 0.1.x compatibility process; -external domains retain their process boundaries. A3S Code consumes the -read-only projection and connects standard MCP/Skill surfaces; it does not gain -component installation authority. +an in-process typed engine with a temporary 0.1.x compatibility process. OCR +uses an explicitly present local Tesseract executable or an explicitly +configured vision provider; it never installs either silently. External +domains retain their process boundaries. A3S Code consumes the read-only +projection and connects standard MCP/Skill surfaces; bounded provider +installation requests still require the parent TUI's authority. Source is split between the facade under `src/` and focused workspace crates under `crates/`. See [Architecture](docs/architecture.md) for package leases, diff --git a/crates/browser-driver/skills/a3s-use-browser/SKILL.md b/crates/browser-driver/skills/a3s-use-browser/SKILL.md index 072d4ef5..ee03a73a 100644 --- a/crates/browser-driver/skills/a3s-use-browser/SKILL.md +++ b/crates/browser-driver/skills/a3s-use-browser/SKILL.md @@ -10,7 +10,10 @@ Use the host surface that is already available: - In an A3S Code `use` worker, call the available `mcp__use_browser__*` tools directly. The host owns installation and MCP - lifecycle; do not run component installation or shell commands there. + lifecycle; do not run component installation or shell commands there. Call + `mcp__use_browser__agent_browser_doctor` first. If its managed browser is + missing, request `mcp__use_browser__agent_browser_install`; the parent TUI + must obtain HITL approval before that mutation can run. - In a CLI-only agent host, use the `a3s use browser ...` commands below. Install the built-in capability and its managed runtime when needed: diff --git a/crates/browser-driver/src/mcp.rs b/crates/browser-driver/src/mcp.rs index 99fb2a30..c1585c07 100644 --- a/crates/browser-driver/src/mcp.rs +++ b/crates/browser-driver/src/mcp.rs @@ -352,6 +352,8 @@ const CORE_PROFILE_TOOLS: &[&str] = &[ TOOL_TAB_CLOSE, TOOL_EVAL, TOOL_CLOSE, + TOOL_DOCTOR, + TOOL_INSTALL, ]; const NETWORK_PROFILE_TOOLS: &[&str] = &[ @@ -3744,6 +3746,8 @@ mod tests { assert!(names.contains(&TOOL_SNAPSHOT)); assert!(names.contains(&TOOL_CLICK)); assert!(names.contains(&TOOL_SCREENSHOT)); + assert!(names.contains(&TOOL_DOCTOR)); + assert!(names.contains(&TOOL_INSTALL)); assert!(names.contains(&TOOL_GET_CDP_URL)); assert!(names.contains(&TOOL_NETWORK_HAR_START)); assert!(names.contains(&TOOL_REACT_SUSPENSE)); diff --git a/crates/extension/src/lib.rs b/crates/extension/src/lib.rs index ffc918a5..cc76e2dc 100644 --- a/crates/extension/src/lib.rs +++ b/crates/extension/src/lib.rs @@ -23,6 +23,9 @@ const RESERVED_ROUTES: &[&str] = &[ "box", "capability", "office", + "office-compat", + "office-native", + "ocr", "capabilities", "component", "extension", @@ -437,7 +440,7 @@ extension "acme/slack" { #[test] fn rejects_reserved_routes() { - for route in ["browser", "box"] { + for route in ["browser", "box", "ocr"] { let manifest = MANIFEST.replace( "route = \"slack\"", &format!("route = \"{route}\""), diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml new file mode 100644 index 00000000..4a5f58aa --- /dev/null +++ b/crates/ocr/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "a3s-use-ocr" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "Typed built-in optical character recognition for A3S Use" + +[lib] +name = "a3s_use_ocr" +path = "src/lib.rs" + +[[bin]] +name = "a3s-use-ocr" +path = "src/main.rs" + +[dependencies] +a3s-use-core = { version = "0.1.1", path = "../core" } +base64.workspace = true +clap.workspace = true +reqwest = { workspace = true, features = ["json"] } +rmcp.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tokio.workspace = true +url.workspace = true + +[dev-dependencies] +axum.workspace = true +tempfile.workspace = true diff --git a/crates/ocr/README.md b/crates/ocr/README.md new file mode 100644 index 00000000..d61b4096 --- /dev/null +++ b/crates/ocr/README.md @@ -0,0 +1,34 @@ +# A3S Use OCR + +`a3s-use-ocr` implements the first-party built-in OCR domain for A3S Use. A3S +Code receives it as `mcp__use_ocr__*` through the release-matched Use registry, +without a separate extension install. It exposes the same typed extraction +through a native CLI and standard stdio MCP, and does not silently install an +OCR provider. + +Provider selection is explicit: + +- `A3S_OCR_PROVIDER=auto|tesseract|vision` +- `A3S_OCR_TESSERACT_EXECUTABLE=/absolute/path/to/tesseract` +- `A3S_OCR_VISION_MODEL=` +- `A3S_OCR_VISION_BASE_URL=https://provider.example/v1/` +- `A3S_OCR_VISION_API_KEY=` +- `A3S_OCR_TIMEOUT_MS=60000` + +`auto` prefers a configured or discoverable local Tesseract executable. It uses +the vision provider only when the vision environment is configured. Remote +vision endpoints require HTTPS and an API key; loopback HTTP is allowed for a +local provider. + +Build and exercise the domain through the Use facade: + +```bash +a3s use ocr doctor --json +a3s use ocr extract ./scan.png --language eng --json +a3s use mcp serve ocr +``` + +The A3S Use release packages the OCR Skill beside the facade binary. A3S Code +can first-use install that verified release and hot-plug the built-in route. +Provider setup remains explicit: local Tesseract never sends source bytes +off-device, while a configured remote vision provider requires parent HITL. diff --git a/crates/ocr/skills/a3s-use-ocr/SKILL.md b/crates/ocr/skills/a3s-use-ocr/SKILL.md new file mode 100644 index 00000000..9ca0950b --- /dev/null +++ b/crates/ocr/skills/a3s-use-ocr/SKILL.md @@ -0,0 +1,48 @@ +--- +name: a3s-use-ocr +description: Extract text and layout evidence from local image files through the built-in A3S Use OCR domain. Use when an agent needs optical character recognition for a PNG, JPEG, WebP, GIF, BMP, or TIFF image and must preserve the source digest, provider disclosure, confidence, and bounding-box evidence. +--- + +# A3S Use OCR + +Use the host-provided A3S Use surface. In an A3S Code `use` worker, call +`mcp__use_ocr__ocr_doctor` and `mcp__use_ocr__ocr_extract` directly. The host +owns the MCP process; do not run a shell command, install a provider, or read the +file through another tool. + +## Workflow + +1. Call `mcp__use_ocr__ocr_doctor`. +2. Confirm which provider is ready and whether `sendsSourceOffDevice` is true. +3. Call `mcp__use_ocr__ocr_extract` with the exact local image path from the + task. Supply language identifiers only when known. +4. Preserve the returned source path, media type, size, and SHA-256 in the + result. Treat text, confidence, and bounding boxes as OCR evidence, not as a + verified transcription. + +The local Tesseract provider does not send the image over the network. The +vision provider sends the complete source image and prompt to its disclosed +endpoint. Do not use a non-loopback vision provider unless the user has +authorized that data transfer. Never install, repair, or switch providers from +inside the `use` worker. + +In a CLI-only host, equivalent commands are: + +```bash +a3s use ocr doctor --json +a3s use ocr extract "$IMAGE" --language eng --json +``` + +`a3s-use-ocr` accepts the same arguments when invoked as a standalone +development binary. + +## Boundaries + +- Only bounded local image files are accepted. URLs and PDF rasterization are + outside this domain. +- Keep the default prompt for faithful transcription. A custom vision prompt + must remain an extraction instruction; do not ask the provider to interpret + unrelated content. +- Never report vision output as calibrated confidence or layout evidence. +- Do not silently fall back from a requested provider. Report typed provider, + source, and response errors to the parent agent. diff --git a/crates/ocr/src/cli.rs b/crates/ocr/src/cli.rs new file mode 100644 index 00000000..ea76bcda --- /dev/null +++ b/crates/ocr/src/cli.rs @@ -0,0 +1,205 @@ +use std::path::PathBuf; + +use a3s_use_core::{UseError, UseResult}; +use clap::error::ErrorKind; +use clap::{Parser, Subcommand, ValueEnum}; +use serde::Serialize; + +use crate::{OcrClient, OcrMcpServer, OcrProviderKind, OcrRequest}; + +#[derive(Debug)] +pub struct CommandOutput { + pub human: String, + pub json: serde_json::Value, + pub exit_code: u8, + pub should_print: bool, +} + +impl CommandOutput { + fn data(value: T) -> UseResult + where + T: Serialize, + { + let data = serde_json::to_value(value).map_err(output_error)?; + let human = serde_json::to_string_pretty(&data).map_err(output_error)?; + Ok(Self { + human, + json: serde_json::json!({ + "schemaVersion": 1, + "ok": true, + "data": data, + }), + exit_code: 0, + should_print: true, + }) + } + + fn text(value: String) -> Self { + Self { + human: value.clone(), + json: serde_json::json!({ + "schemaVersion": 1, + "ok": true, + "data": { "text": value }, + }), + exit_code: 0, + should_print: true, + } + } + + fn silent() -> Self { + Self { + human: String::new(), + json: serde_json::Value::Null, + exit_code: 0, + should_print: false, + } + } +} + +#[derive(Debug, Parser)] +#[command( + name = "a3s-use-ocr", + version, + about = "Typed built-in OCR for A3S Use", + arg_required_else_help = true +)] +struct Cli { + /// Emit one versioned JSON document. + #[arg(long, global = true)] + json: bool, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Inspect provider readiness without reading an image. + Doctor, + /// Extract text and available layout evidence from one local image. + Extract { + path: PathBuf, + /// OCR language identifier; may be repeated. + #[arg(long = "language")] + languages: Vec, + /// Tesseract page segmentation mode from 0 through 13. + #[arg(long = "psm")] + page_segmentation_mode: Option, + /// Override the configured OCR provider for this call. + #[arg(long, value_enum)] + provider: Option, + /// Vision-only extraction instruction. + #[arg(long)] + prompt: Option, + }, + /// Run an extension protocol surface. + Serve { + /// Serve standard MCP over stdin/stdout. + #[arg(long)] + mcp: bool, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum ProviderArg { + Auto, + Tesseract, + Vision, +} + +impl From for OcrProviderKind { + fn from(value: ProviderArg) -> Self { + match value { + ProviderArg::Auto => Self::Auto, + ProviderArg::Tesseract => Self::Tesseract, + ProviderArg::Vision => Self::Vision, + } + } +} + +pub async fn run(args: Vec) -> UseResult { + let mut argv = vec!["a3s-use-ocr".to_string()]; + argv.extend(args); + let cli = match Cli::try_parse_from(argv) { + Ok(cli) => cli, + Err(error) + if matches!( + error.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + ) => + { + return Ok(CommandOutput::text(error.to_string())); + } + Err(error) => return Err(usage_error(error.to_string())), + }; + + if let Command::Serve { mcp } = &cli.command { + if !mcp { + return Err(usage_error("serve requires --mcp")); + } + if cli.json { + return Err(usage_error("--json cannot be combined with serve --mcp")); + } + OcrMcpServer::from_env()?.serve_stdio().await?; + return Ok(CommandOutput::silent()); + } + + let client = OcrClient::from_env()?; + match cli.command { + Command::Doctor => CommandOutput::data(client.diagnostic()), + Command::Extract { + path, + languages, + page_segmentation_mode, + provider, + prompt, + } => CommandOutput::data( + client + .extract(OcrRequest { + path, + languages, + page_segmentation_mode, + provider: provider.map(Into::into), + prompt, + }) + .await?, + ), + Command::Serve { .. } => Err(UseError::new( + "use.ocr.command_invalid", + "OCR MCP command dispatch reached an invalid state.", + )), + } +} + +fn output_error(error: serde_json::Error) -> UseError { + UseError::new( + "use.ocr.output_invalid", + format!("Failed to encode OCR command output: {error}"), + ) +} + +fn usage_error(message: impl Into) -> UseError { + UseError::new("use.ocr.usage_invalid", message).with_suggestion("Run 'a3s use ocr --help'.") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn doctor_is_versioned_even_when_no_provider_is_ready() { + let output = run(vec!["doctor".to_string(), "--json".to_string()]) + .await + .unwrap(); + assert_eq!(output.json["schemaVersion"], 1); + assert_eq!(output.json["ok"], true); + assert!(output.json["data"]["readiness"].is_string()); + } + + #[tokio::test] + async fn serve_requires_an_explicit_protocol() { + let error = run(vec!["serve".to_string()]).await.unwrap_err(); + assert_eq!(error.code, "use.ocr.usage_invalid"); + } +} diff --git a/crates/ocr/src/client.rs b/crates/ocr/src/client.rs new file mode 100644 index 00000000..21fb3494 --- /dev/null +++ b/crates/ocr/src/client.rs @@ -0,0 +1,649 @@ +use std::collections::BTreeMap; +use std::path::Path; +#[cfg(all(test, unix))] +use std::path::PathBuf; +use std::process::Stdio; + +use a3s_use_core::{Artifact, UseError, UseResult}; +use base64::Engine; +use sha2::{Digest, Sha256}; +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +use crate::models::{OcrBlock, OcrBoundingBox, OcrProviderKind, OcrRequest, OcrResult}; +use crate::provider::{Provider, ProviderConfig}; +use crate::OcrDiagnostic; + +const MAX_INPUT_BYTES: u64 = 32 * 1024 * 1024; +const MAX_PROVIDER_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +const DEFAULT_VISION_PROMPT: &str = "Transcribe all visible text in reading order. Preserve line breaks and meaningful spacing. Return only the transcription; do not summarize, translate, or wrap it in Markdown."; + +#[derive(Clone)] +pub struct OcrClient { + providers: ProviderConfig, + http: reqwest::Client, +} + +impl OcrClient { + pub fn from_env() -> UseResult { + Self::from_provider_config(ProviderConfig::from_env()?) + } + + fn from_provider_config(providers: ProviderConfig) -> UseResult { + let http = reqwest::Client::builder() + .user_agent(concat!("a3s-use-ocr/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| { + UseError::new( + "use.ocr.client_failed", + format!("Failed to initialize the OCR HTTP client: {error}"), + ) + })?; + Ok(Self { providers, http }) + } + + #[cfg(all(test, unix))] + pub(crate) fn with_tesseract(executable: PathBuf) -> UseResult { + Self::from_provider_config(ProviderConfig::tesseract(executable)) + } + + pub fn diagnostic(&self) -> OcrDiagnostic { + self.providers.diagnostic() + } + + pub async fn extract(&self, request: OcrRequest) -> UseResult { + validate_request(&request)?; + let source = read_source(&request.path).await?; + let provider = self + .providers + .resolve(request.provider.unwrap_or(OcrProviderKind::Auto))?; + let languages = if request.languages.is_empty() { + vec!["eng".to_string()] + } else { + request.languages.clone() + }; + + let (text, blocks, warnings) = match &provider { + Provider::Tesseract { + executable, + timeout, + } => { + let output = run_tesseract( + executable, + &source.artifact.path, + &languages, + request.page_segmentation_mode, + *timeout, + ) + .await?; + let (text, blocks) = parse_tesseract_tsv(&output)?; + (text, blocks, Vec::new()) + } + Provider::Vision { + endpoint, + api_key, + model, + timeout, + } => { + let text = self + .run_vision( + endpoint, + api_key.as_deref(), + model, + &source, + request.prompt.as_deref(), + *timeout, + ) + .await?; + let blocks = (!text.is_empty()) + .then(|| OcrBlock { + page: 1, + text: text.clone(), + confidence: None, + bounding_box: None, + }) + .into_iter() + .collect(); + ( + text, + blocks, + vec![ + "The vision provider does not return calibrated OCR confidence or bounding boxes." + .to_string(), + ], + ) + } + }; + + Ok(OcrResult { + provider: provider.kind(), + source: source.artifact, + languages, + text, + blocks, + warnings, + }) + } + + async fn run_vision( + &self, + endpoint: &url::Url, + api_key: Option<&str>, + model: &str, + source: &SourceImage, + prompt: Option<&str>, + timeout: std::time::Duration, + ) -> UseResult { + let encoded = base64::engine::general_purpose::STANDARD.encode(&source.bytes); + let data_url = format!("data:{};base64,{encoded}", source.artifact.media_type); + let prompt = prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_VISION_PROMPT); + let body = serde_json::json!({ + "model": model, + "temperature": 0, + "messages": [{ + "role": "user", + "content": [ + { "type": "text", "text": prompt }, + { + "type": "image_url", + "image_url": { + "url": data_url, + "detail": "high" + } + } + ] + }] + }); + let mut request = self + .http + .post(endpoint.clone()) + .timeout(timeout) + .json(&body); + if let Some(api_key) = api_key { + request = request.bearer_auth(api_key); + } + let response = request.send().await.map_err(|error| { + UseError::new( + "use.ocr.vision_request_failed", + format!("The vision OCR request failed: {error}"), + ) + .with_detail("endpoint", redacted_endpoint(endpoint)) + })?; + let status = response.status(); + let bytes = response.bytes().await.map_err(|error| { + UseError::new( + "use.ocr.vision_response_invalid", + format!("Failed to read the vision OCR response: {error}"), + ) + })?; + if bytes.len() > MAX_PROVIDER_OUTPUT_BYTES { + return Err(UseError::new( + "use.ocr.output_too_large", + "The vision OCR provider response exceeded 8 MiB.", + )); + } + if !status.is_success() { + let message = String::from_utf8_lossy(&bytes); + return Err(UseError::new( + "use.ocr.vision_request_failed", + format!( + "The vision OCR provider returned HTTP {status}: {}", + bounded_text(&message, 1024) + ), + ) + .with_detail("status", u64::from(status.as_u16()))); + } + let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| { + UseError::new( + "use.ocr.vision_response_invalid", + format!("The vision OCR provider returned invalid JSON: {error}"), + ) + })?; + let content = value.pointer("/choices/0/message/content").ok_or_else(|| { + UseError::new( + "use.ocr.vision_response_invalid", + "The vision OCR response did not contain choices[0].message.content.", + ) + })?; + let text = vision_content_text(content)?; + Ok(text.trim().to_string()) + } +} + +struct SourceImage { + artifact: Artifact, + bytes: Vec, +} + +async fn read_source(path: &Path) -> UseResult { + let canonical = tokio::fs::canonicalize(path).await.map_err(|error| { + UseError::new( + "use.ocr.source_unreadable", + format!("Failed to resolve OCR source '{}': {error}", path.display()), + ) + })?; + let metadata = tokio::fs::metadata(&canonical).await.map_err(|error| { + UseError::new( + "use.ocr.source_unreadable", + format!( + "Failed to inspect OCR source '{}': {error}", + canonical.display() + ), + ) + })?; + if !metadata.is_file() { + return Err(UseError::new( + "use.ocr.source_invalid", + format!( + "OCR source '{}' is not a regular file.", + canonical.display() + ), + )); + } + if metadata.len() == 0 || metadata.len() > MAX_INPUT_BYTES { + return Err(UseError::new( + "use.ocr.source_too_large", + format!( + "OCR source '{}' must contain between 1 byte and 32 MiB.", + canonical.display() + ), + ) + .with_detail("size", metadata.len())); + } + let file = tokio::fs::File::open(&canonical).await.map_err(|error| { + UseError::new( + "use.ocr.source_unreadable", + format!( + "Failed to open OCR source '{}': {error}", + canonical.display() + ), + ) + })?; + let mut bytes = Vec::with_capacity(metadata.len().min(MAX_INPUT_BYTES) as usize); + file.take(MAX_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .await + .map_err(|error| { + UseError::new( + "use.ocr.source_unreadable", + format!( + "Failed to read OCR source '{}': {error}", + canonical.display() + ), + ) + })?; + if bytes.len() as u64 > MAX_INPUT_BYTES { + return Err(UseError::new( + "use.ocr.source_too_large", + format!( + "OCR source '{}' must not exceed 32 MiB.", + canonical.display() + ), + ) + .with_detail("sizeAtLeast", MAX_INPUT_BYTES + 1)); + } + let media_type = detect_image_type(&bytes).ok_or_else(|| { + UseError::new( + "use.ocr.source_type_unsupported", + "OCR accepts PNG, JPEG, WebP, GIF, BMP, and TIFF image bytes.", + ) + })?; + let digest = Sha256::digest(&bytes); + Ok(SourceImage { + artifact: Artifact { + path: canonical, + media_type: media_type.to_string(), + size: bytes.len() as u64, + sha256: format!("{digest:x}"), + }, + bytes, + }) +} + +fn validate_request(request: &OcrRequest) -> UseResult<()> { + if request.languages.len() > 16 { + return Err(UseError::new( + "use.ocr.languages_invalid", + "At most 16 OCR language identifiers may be requested.", + )); + } + for language in &request.languages { + if language.is_empty() + || language.len() > 32 + || !language + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(UseError::new( + "use.ocr.languages_invalid", + format!("OCR language identifier '{language}' is invalid."), + )); + } + } + if request.page_segmentation_mode.is_some_and(|mode| mode > 13) { + return Err(UseError::new( + "use.ocr.page_segmentation_invalid", + "Tesseract page segmentation mode must be from 0 through 13.", + )); + } + if request + .prompt + .as_ref() + .is_some_and(|prompt| prompt.len() > 8 * 1024) + { + return Err(UseError::new( + "use.ocr.prompt_too_large", + "The vision OCR prompt must not exceed 8192 bytes.", + )); + } + Ok(()) +} + +async fn run_tesseract( + executable: &Path, + source: &Path, + languages: &[String], + page_segmentation_mode: Option, + timeout: std::time::Duration, +) -> UseResult> { + let mut command = Command::new(executable); + command + .arg(source) + .arg("stdout") + .arg("-l") + .arg(languages.join("+")) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(mode) = page_segmentation_mode { + command.arg("--psm").arg(mode.to_string()); + } + command.arg("tsv"); + + let output = tokio::time::timeout(timeout, command.output()) + .await + .map_err(|_| { + UseError::new( + "use.ocr.provider_timeout", + format!( + "Tesseract exceeded the {} ms OCR timeout.", + timeout.as_millis() + ), + ) + })? + .map_err(|error| { + UseError::new( + "use.ocr.provider_failed", + format!( + "Failed to launch Tesseract executable '{}': {error}", + executable.display() + ), + ) + })?; + if output.stdout.len() > MAX_PROVIDER_OUTPUT_BYTES + || output.stderr.len() > MAX_PROVIDER_OUTPUT_BYTES + { + return Err(UseError::new( + "use.ocr.output_too_large", + "Tesseract output exceeded 8 MiB.", + )); + } + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(UseError::new( + "use.ocr.provider_failed", + format!( + "Tesseract exited with {}: {}", + output.status, + bounded_text(&stderr, 2048) + ), + )); + } + Ok(output.stdout) +} + +#[derive(Default)] +struct LineAccumulator { + page: u32, + words: Vec, + confidence_sum: f32, + confidence_count: usize, + left: u32, + top: u32, + right: u32, + bottom: u32, +} + +fn parse_tesseract_tsv(output: &[u8]) -> UseResult<(String, Vec)> { + let output = std::str::from_utf8(output).map_err(|error| { + UseError::new( + "use.ocr.provider_output_invalid", + format!("Tesseract TSV output was not UTF-8: {error}"), + ) + })?; + let mut lines = BTreeMap::<(u32, u32, u32, u32), LineAccumulator>::new(); + for (index, row) in output.lines().enumerate() { + if index == 0 && row.starts_with("level\t") { + continue; + } + if row.trim().is_empty() { + continue; + } + let columns = row.splitn(12, '\t').collect::>(); + if columns.len() != 12 { + return Err(UseError::new( + "use.ocr.provider_output_invalid", + format!( + "Tesseract TSV row {} did not contain 12 columns.", + index + 1 + ), + )); + } + let level = parse_u32(columns[0], index)?; + if level != 5 || columns[11].trim().is_empty() { + continue; + } + let page = parse_u32(columns[1], index)?; + let block = parse_u32(columns[2], index)?; + let paragraph = parse_u32(columns[3], index)?; + let line = parse_u32(columns[4], index)?; + let left = parse_u32(columns[6], index)?; + let top = parse_u32(columns[7], index)?; + let width = parse_u32(columns[8], index)?; + let height = parse_u32(columns[9], index)?; + let confidence = columns[10] + .parse::() + .ok() + .filter(|value| *value >= 0.0); + let entry = lines + .entry((page, block, paragraph, line)) + .or_insert_with(|| LineAccumulator { + page, + left, + top, + right: left.saturating_add(width), + bottom: top.saturating_add(height), + ..LineAccumulator::default() + }); + entry.words.push(columns[11].trim().to_string()); + if let Some(confidence) = confidence { + entry.confidence_sum += confidence; + entry.confidence_count += 1; + } + entry.left = entry.left.min(left); + entry.top = entry.top.min(top); + entry.right = entry.right.max(left.saturating_add(width)); + entry.bottom = entry.bottom.max(top.saturating_add(height)); + } + let blocks = lines + .into_values() + .filter_map(|line| { + let text = line.words.join(" "); + (!text.is_empty()).then(|| OcrBlock { + page: line.page, + text, + confidence: (line.confidence_count > 0) + .then(|| line.confidence_sum / line.confidence_count as f32), + bounding_box: Some(OcrBoundingBox { + x: line.left, + y: line.top, + width: line.right.saturating_sub(line.left), + height: line.bottom.saturating_sub(line.top), + }), + }) + }) + .collect::>(); + let text = blocks + .iter() + .map(|block| block.text.as_str()) + .collect::>() + .join("\n"); + Ok((text, blocks)) +} + +fn parse_u32(value: &str, row: usize) -> UseResult { + value.parse::().map_err(|_| { + UseError::new( + "use.ocr.provider_output_invalid", + format!( + "Tesseract TSV row {} contained an invalid integer.", + row + 1 + ), + ) + }) +} + +fn vision_content_text(content: &serde_json::Value) -> UseResult { + if let Some(text) = content.as_str() { + return Ok(text.to_string()); + } + let Some(parts) = content.as_array() else { + return Err(UseError::new( + "use.ocr.vision_response_invalid", + "Vision OCR message content was neither text nor a text-part array.", + )); + }; + let text = parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(serde_json::Value::as_str) + .or_else(|| part.as_str()) + }) + .collect::>() + .join(""); + if text.is_empty() { + return Err(UseError::new( + "use.ocr.vision_response_invalid", + "Vision OCR message content did not contain text.", + )); + } + Ok(text) +} + +fn detect_image_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if bytes.starts_with(b"\xff\xd8\xff") { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif") + } else if bytes.starts_with(b"BM") { + Some("image/bmp") + } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { + Some("image/tiff") + } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { + Some("image/webp") + } else { + None + } +} + +fn bounded_text(value: &str, max: usize) -> String { + let mut text = value.chars().take(max).collect::(); + if value.chars().count() > max { + text.push('…'); + } + text +} + +fn redacted_endpoint(endpoint: &url::Url) -> String { + let mut redacted = endpoint.clone(); + redacted.set_query(None); + redacted.set_fragment(None); + redacted.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[test] + fn parses_tesseract_words_into_ordered_lines() { + let tsv = b"level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext\n5\t1\t1\t1\t1\t1\t10\t20\t30\t10\t95.0\tHello\n5\t1\t1\t1\t1\t2\t45\t20\t35\t10\t85.0\tworld\n5\t1\t1\t1\t2\t1\t10\t40\t20\t10\t90.0\tNext\n"; + let (text, blocks) = parse_tesseract_tsv(tsv).unwrap(); + assert_eq!(text, "Hello world\nNext"); + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].confidence, Some(90.0)); + assert_eq!( + blocks[0].bounding_box, + Some(OcrBoundingBox { + x: 10, + y: 20, + width: 70, + height: 10, + }) + ); + } + + #[test] + fn detects_supported_image_signatures() { + assert_eq!( + detect_image_type(b"\x89PNG\r\n\x1a\nrest"), + Some("image/png") + ); + assert_eq!(detect_image_type(b"\xff\xd8\xffrest"), Some("image/jpeg")); + assert_eq!(detect_image_type(b"not an image"), None); + } + + #[cfg(unix)] + #[tokio::test] + async fn local_provider_extracts_a_real_bounded_source_through_its_process_boundary() { + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("tesseract-fixture"); + std::fs::write( + &executable, + "#!/bin/sh\nprintf 'level\\tpage_num\\tblock_num\\tpar_num\\tline_num\\tword_num\\tleft\\ttop\\twidth\\theight\\tconf\\ttext\\n5\\t1\\t1\\t1\\t1\\t1\\t2\\t3\\t20\\t8\\t98.0\\tA3S\\n5\\t1\\t1\\t1\\t1\\t2\\t24\\t3\\t30\\t8\\t96.0\\tUse\\n'\n", + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + let image = temp.path().join("scan.png"); + std::fs::write(&image, b"\x89PNG\r\n\x1a\nfixture").unwrap(); + + let result = OcrClient::with_tesseract(executable) + .unwrap() + .extract(OcrRequest { + path: image, + languages: vec!["eng".to_string()], + page_segmentation_mode: Some(6), + provider: Some(OcrProviderKind::Tesseract), + prompt: None, + }) + .await + .unwrap(); + + assert_eq!(result.provider, OcrProviderKind::Tesseract); + assert_eq!(result.text, "A3S Use"); + assert_eq!(result.blocks.len(), 1); + assert_eq!(result.source.media_type, "image/png"); + assert_eq!(result.source.sha256.len(), 64); + } +} diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs new file mode 100644 index 00000000..a1bd1740 --- /dev/null +++ b/crates/ocr/src/lib.rs @@ -0,0 +1,18 @@ +//! Typed optical character recognition for A3S Use. +//! +//! OCR is a first-party built-in Use domain and remains process-isolated from +//! A3S Code through its standard MCP server. The crate supports a local +//! Tesseract executable and an explicitly configured OpenAI-compatible vision +//! endpoint without silently installing either provider. + +pub mod cli; +mod client; +pub mod mcp; +mod models; +mod provider; + +pub use client::OcrClient; +pub use mcp::OcrMcpServer; +pub use models::{OcrBlock, OcrBoundingBox, OcrDiagnostic, OcrProviderKind, OcrRequest, OcrResult}; + +pub use a3s_use_core::{Artifact, Readiness, UseError, UseResult}; diff --git a/crates/ocr/src/main.rs b/crates/ocr/src/main.rs new file mode 100644 index 00000000..4bba3c58 --- /dev/null +++ b/crates/ocr/src/main.rs @@ -0,0 +1,39 @@ +use std::process::ExitCode; + +#[tokio::main] +async fn main() -> ExitCode { + let args = std::env::args().skip(1).collect::>(); + let json = args.iter().any(|argument| argument == "--json"); + match a3s_use_ocr::cli::run(args).await { + Ok(output) => { + if output.should_print && json { + println!( + "{}", + serde_json::to_string_pretty(&output.json).unwrap_or_default() + ); + } else if output.should_print && !output.human.is_empty() { + println!("{}", output.human); + } + ExitCode::from(output.exit_code) + } + Err(error) => { + if json { + let output = serde_json::json!({ + "schemaVersion": 1, + "ok": false, + "error": error, + }); + println!( + "{}", + serde_json::to_string_pretty(&output).unwrap_or_default() + ); + } else { + eprintln!("a3s-use-ocr: {error}"); + if let Some(suggestion) = &error.suggestion { + eprintln!("suggestion: {suggestion}"); + } + } + ExitCode::from(1) + } + } +} diff --git a/crates/ocr/src/mcp.rs b/crates/ocr/src/mcp.rs new file mode 100644 index 00000000..f2de4ada --- /dev/null +++ b/crates/ocr/src/mcp.rs @@ -0,0 +1,170 @@ +//! Standard MCP tools for the built-in OCR domain. + +use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters}; +use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerInfo}; +use rmcp::{tool, tool_handler, tool_router, ServerHandler, ServiceExt}; +use serde::Serialize; + +use crate::{OcrClient, OcrDiagnostic, OcrRequest, OcrResult, UseError, UseResult}; + +#[derive(Clone)] +pub struct OcrMcpServer { + client: OcrClient, + tool_router: ToolRouter, +} + +impl OcrMcpServer { + pub fn new(client: OcrClient) -> Self { + Self { + client, + tool_router: Self::tool_router(), + } + } + + pub fn from_env() -> UseResult { + Ok(Self::new(OcrClient::from_env()?)) + } + + /// Serve standard MCP framing over stdin/stdout until the peer disconnects. + pub async fn serve_stdio(self) -> UseResult<()> { + let service = self + .serve(rmcp::transport::stdio()) + .await + .map_err(|error| mcp_error("start", error))?; + service + .waiting() + .await + .map_err(|error| mcp_error("run", error))?; + Ok(()) + } +} + +#[tool_router] +impl OcrMcpServer { + #[tool( + name = "ocr_doctor", + description = "Inspect OCR provider readiness without reading an image or making a network request", + output_schema = rmcp::handler::server::tool::cached_schema_for_type::(), + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn ocr_doctor(&self) -> Result { + Ok(tool_result(Ok(self.client.diagnostic()))) + } + + #[tool( + name = "ocr_extract", + description = "Extract text and available layout evidence from one bounded local image; the configured vision provider may send source bytes to its disclosed endpoint", + output_schema = rmcp::handler::server::tool::cached_schema_for_type::(), + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn ocr_extract( + &self, + Parameters(request): Parameters, + ) -> Result { + Ok(tool_result(self.client.extract(request).await)) + } +} + +#[tool_handler] +impl ServerHandler for OcrMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation { + name: "a3s-use-ocr".to_string(), + title: Some("A3S Use OCR".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + icons: None, + website_url: Some("https://github.com/A3S-Lab/Use".to_string()), + }, + instructions: Some( + "Call ocr_doctor first. Use ocr_extract only for a local image path supplied by the task. A vision provider may send the complete image to its configured endpoint; do not use it without the user's authority. Preserve the source SHA-256 and distinguish OCR text from verified source text." + .to_string(), + ), + ..Default::default() + } + } +} + +fn tool_result(result: UseResult) -> CallToolResult +where + T: Serialize, +{ + match result { + Ok(output) => match serde_json::to_value(output) { + Ok(value) => CallToolResult::structured(value), + Err(error) => tool_error(UseError::new( + "use.ocr.output_invalid", + format!("Failed to encode OCR MCP output: {error}"), + )), + }, + Err(error) => tool_error(error), + } +} + +fn tool_error(error: UseError) -> CallToolResult { + CallToolResult::structured_error(serde_json::to_value(error).unwrap_or_else(|_| { + serde_json::json!({ + "code": "use.error_encoding_failed", + "message": "Failed to encode A3S Use error." + }) + })) +} + +fn mcp_error(action: &str, error: impl std::fmt::Display) -> UseError { + UseError::new( + "use.ocr.mcp_failed", + format!("Failed to {action} the OCR MCP server: {error}"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_exposes_typed_annotated_ocr_tools() { + let client = OcrClient::from_env().unwrap(); + let server = OcrMcpServer::new(client); + let mut tools = server.tool_router.list_all(); + tools.sort_by(|left, right| left.name.cmp(&right.name)); + assert_eq!( + tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(), + ["ocr_doctor", "ocr_extract"] + ); + let doctor = tools.iter().find(|tool| tool.name == "ocr_doctor").unwrap(); + let extract = tools + .iter() + .find(|tool| tool.name == "ocr_extract") + .unwrap(); + assert!(doctor.output_schema.is_some()); + assert!(extract.output_schema.is_some()); + assert_eq!( + doctor + .annotations + .as_ref() + .and_then(|annotations| annotations.open_world_hint), + Some(false) + ); + assert_eq!( + extract + .annotations + .as_ref() + .and_then(|annotations| annotations.open_world_hint), + Some(true) + ); + } +} diff --git a/crates/ocr/src/models.rs b/crates/ocr/src/models.rs new file mode 100644 index 00000000..6cbf6bae --- /dev/null +++ b/crates/ocr/src/models.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; + +use a3s_use_core::{Artifact, Readiness}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum OcrProviderKind { + Auto, + Tesseract, + Vision, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct OcrRequest { + #[schemars(description = "Local PNG, JPEG, WebP, GIF, BMP, or TIFF image path")] + pub path: PathBuf, + #[serde(default)] + #[schemars( + description = "OCR language identifiers; Tesseract values are joined with '+', for example ['eng', 'chi_sim']" + )] + pub languages: Vec, + #[serde(default)] + #[schemars(description = "Optional Tesseract page segmentation mode from 0 through 13")] + pub page_segmentation_mode: Option, + #[serde(default)] + #[schemars(description = "Override the configured provider for this call")] + pub provider: Option, + #[serde(default)] + #[schemars(description = "Optional extraction instruction used only by the vision provider")] + pub prompt: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct OcrBoundingBox { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct OcrBlock { + pub page: u32, + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bounding_box: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct OcrResult { + pub provider: OcrProviderKind, + #[schemars(with = "OcrArtifactSchema")] + pub source: Artifact, + pub languages: Vec, + pub text: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct OcrDiagnostic { + #[schemars(with = "OcrReadinessSchema")] + pub readiness: Readiness, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub executable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + pub sends_source_off_device: bool, + pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub suggestions: Vec, +} + +#[derive(schemars::JsonSchema)] +#[allow(dead_code)] +struct OcrArtifactSchema { + path: PathBuf, + media_type: String, + size: u64, + sha256: String, +} + +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "kebab-case")] +#[allow(dead_code)] +enum OcrReadinessSchema { + Ready, + Missing, + Broken, + Unknown, +} diff --git a/crates/ocr/src/provider.rs b/crates/ocr/src/provider.rs new file mode 100644 index 00000000..eb8880e0 --- /dev/null +++ b/crates/ocr/src/provider.rs @@ -0,0 +1,393 @@ +use std::env; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use a3s_use_core::{Readiness, UseError, UseResult}; +use url::Url; + +use crate::{OcrDiagnostic, OcrProviderKind}; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +const DEFAULT_VISION_BASE_URL: &str = "https://api.openai.com/v1/"; + +#[derive(Debug, Clone)] +pub(crate) enum Provider { + Tesseract { + executable: PathBuf, + timeout: Duration, + }, + Vision { + endpoint: Url, + api_key: Option, + model: String, + timeout: Duration, + }, +} + +impl Provider { + pub(crate) fn kind(&self) -> OcrProviderKind { + match self { + Self::Tesseract { .. } => OcrProviderKind::Tesseract, + Self::Vision { .. } => OcrProviderKind::Vision, + } + } + + pub(crate) fn diagnostic(&self) -> OcrDiagnostic { + match self { + Self::Tesseract { executable, .. } => OcrDiagnostic { + readiness: Readiness::Ready, + provider: Some(OcrProviderKind::Tesseract), + executable: Some(executable.clone()), + endpoint: None, + model: None, + sends_source_off_device: false, + message: "The local Tesseract OCR provider is ready.".to_string(), + suggestions: Vec::new(), + }, + Self::Vision { + endpoint, model, .. + } => OcrDiagnostic { + readiness: Readiness::Ready, + provider: Some(OcrProviderKind::Vision), + executable: None, + endpoint: Some(redacted_endpoint(endpoint)), + model: Some(model.clone()), + sends_source_off_device: !is_loopback(endpoint), + message: "The explicitly configured vision OCR provider is ready.".to_string(), + suggestions: Vec::new(), + }, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ProviderConfig { + requested: OcrProviderKind, + tesseract: Option, + vision: Option, + timeout: Duration, +} + +#[derive(Debug, Clone)] +struct VisionConfig { + endpoint: Url, + api_key: Option, + model: String, +} + +impl ProviderConfig { + pub(crate) fn from_env() -> UseResult { + let requested = match env::var("A3S_OCR_PROVIDER") + .unwrap_or_else(|_| "auto".to_string()) + .trim() + { + "" | "auto" => OcrProviderKind::Auto, + "tesseract" => OcrProviderKind::Tesseract, + "vision" => OcrProviderKind::Vision, + value => { + return Err(UseError::new( + "use.ocr.provider_invalid", + format!("Unknown OCR provider '{value}'; expected auto, tesseract, or vision."), + )) + } + }; + + let timeout = timeout_from_env()?; + let tesseract = env::var_os("A3S_OCR_TESSERACT_EXECUTABLE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| find_on_path("tesseract")); + let vision = vision_config_from_env()?; + Ok(Self { + requested, + tesseract, + vision, + timeout, + }) + } + + #[cfg(all(test, unix))] + pub(crate) fn tesseract(executable: PathBuf) -> Self { + Self { + requested: OcrProviderKind::Tesseract, + tesseract: Some(executable), + vision: None, + timeout: DEFAULT_TIMEOUT, + } + } + + pub(crate) fn diagnostic(&self) -> OcrDiagnostic { + match self.resolve(self.requested) { + Ok(provider) => provider.diagnostic(), + Err(error) => OcrDiagnostic { + readiness: Readiness::Missing, + provider: match self.requested { + OcrProviderKind::Auto => None, + provider => Some(provider), + }, + executable: self.tesseract.clone(), + endpoint: self + .vision + .as_ref() + .map(|vision| redacted_endpoint(&vision.endpoint)), + model: self.vision.as_ref().map(|vision| vision.model.clone()), + sends_source_off_device: self + .vision + .as_ref() + .is_some_and(|vision| !is_loopback(&vision.endpoint)), + message: error.message, + suggestions: error.suggestion.into_iter().collect(), + }, + } + } + + pub(crate) fn resolve(&self, requested: OcrProviderKind) -> UseResult { + let requested = if requested == OcrProviderKind::Auto { + self.requested + } else { + requested + }; + match requested { + OcrProviderKind::Auto => { + if let Some(executable) = &self.tesseract { + return tesseract_provider(executable, self.timeout); + } + if let Some(vision) = &self.vision { + return Ok(vision_provider(vision, self.timeout)); + } + Err(missing_provider()) + } + OcrProviderKind::Tesseract => self + .tesseract + .as_ref() + .ok_or_else(missing_tesseract) + .and_then(|path| tesseract_provider(path, self.timeout)), + OcrProviderKind::Vision => self + .vision + .as_ref() + .map(|vision| vision_provider(vision, self.timeout)) + .ok_or_else(missing_vision), + } + } +} + +fn tesseract_provider(path: &Path, timeout: Duration) -> UseResult { + let path = std::fs::canonicalize(path).map_err(|error| { + UseError::new( + "use.ocr.provider_missing", + format!( + "Configured Tesseract executable '{}' is not readable: {error}", + path.display() + ), + ) + .with_suggestion( + "Install Tesseract explicitly or configure the vision provider; A3S Use will not install an OCR provider automatically.", + ) + })?; + let metadata = std::fs::metadata(&path).map_err(|error| { + UseError::new( + "use.ocr.provider_missing", + format!( + "Configured Tesseract executable '{}' is not readable: {error}", + path.display() + ), + ) + })?; + if !metadata.is_file() { + return Err(UseError::new( + "use.ocr.provider_invalid", + format!( + "Configured Tesseract path '{}' is not a regular file.", + path.display() + ), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 == 0 { + return Err(UseError::new( + "use.ocr.provider_invalid", + format!( + "Configured Tesseract path '{}' is not executable.", + path.display() + ), + )); + } + } + Ok(Provider::Tesseract { + executable: path, + timeout, + }) +} + +fn vision_provider(config: &VisionConfig, timeout: Duration) -> Provider { + Provider::Vision { + endpoint: config.endpoint.clone(), + api_key: config.api_key.clone(), + model: config.model.clone(), + timeout, + } +} + +fn vision_config_from_env() -> UseResult> { + let model = env::var("A3S_OCR_VISION_MODEL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let base_url = env::var("A3S_OCR_VISION_BASE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let api_key = env::var("A3S_OCR_VISION_API_KEY") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + if model.is_none() && base_url.is_none() && api_key.is_none() { + return Ok(None); + } + let model = model.ok_or_else(|| { + UseError::new( + "use.ocr.vision_config_invalid", + "A3S_OCR_VISION_MODEL is required when the vision OCR provider is configured.", + ) + })?; + let mut base = base_url.unwrap_or_else(|| DEFAULT_VISION_BASE_URL.to_string()); + if !base.ends_with('/') { + base.push('/'); + } + let base = Url::parse(&base).map_err(|error| { + UseError::new( + "use.ocr.vision_config_invalid", + format!("A3S_OCR_VISION_BASE_URL is invalid: {error}"), + ) + })?; + validate_endpoint(&base, api_key.as_deref())?; + let endpoint = base.join("chat/completions").map_err(|error| { + UseError::new( + "use.ocr.vision_config_invalid", + format!("Failed to resolve the vision OCR endpoint: {error}"), + ) + })?; + Ok(Some(VisionConfig { + endpoint, + api_key, + model, + })) +} + +fn validate_endpoint(endpoint: &Url, api_key: Option<&str>) -> UseResult<()> { + if !endpoint.username().is_empty() || endpoint.password().is_some() { + return Err(UseError::new( + "use.ocr.vision_config_invalid", + "The vision OCR endpoint must not contain embedded credentials.", + )); + } + if endpoint.scheme() != "https" && !(endpoint.scheme() == "http" && is_loopback(endpoint)) { + return Err(UseError::new( + "use.ocr.vision_config_invalid", + "The vision OCR endpoint must use HTTPS; loopback HTTP is allowed for local providers.", + )); + } + if !is_loopback(endpoint) && api_key.is_none() { + return Err(UseError::new( + "use.ocr.vision_config_invalid", + "A3S_OCR_VISION_API_KEY is required for a non-loopback vision endpoint.", + )); + } + Ok(()) +} + +fn timeout_from_env() -> UseResult { + let Some(value) = env::var("A3S_OCR_TIMEOUT_MS").ok() else { + return Ok(DEFAULT_TIMEOUT); + }; + let millis = value.parse::().map_err(|_| { + UseError::new( + "use.ocr.timeout_invalid", + "A3S_OCR_TIMEOUT_MS must be an integer from 1 through 300000.", + ) + })?; + if !(1..=300_000).contains(&millis) { + return Err(UseError::new( + "use.ocr.timeout_invalid", + "A3S_OCR_TIMEOUT_MS must be an integer from 1 through 300000.", + )); + } + Ok(Duration::from_millis(millis)) +} + +fn find_on_path(name: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|directory| directory.join(executable_name(name))) + .find(|candidate| candidate.is_file()) +} + +fn executable_name(name: &str) -> String { + if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_string() + } +} + +fn missing_provider() -> UseError { + UseError::new( + "use.ocr.provider_missing", + "No OCR provider is configured or discoverable.", + ) + .with_suggestion( + "Install Tesseract explicitly, set A3S_OCR_TESSERACT_EXECUTABLE, or configure A3S_OCR_VISION_MODEL, A3S_OCR_VISION_BASE_URL, and A3S_OCR_VISION_API_KEY.", + ) +} + +fn missing_tesseract() -> UseError { + UseError::new( + "use.ocr.provider_missing", + "The Tesseract OCR provider is not installed or configured.", + ) + .with_suggestion( + "Install Tesseract explicitly or set A3S_OCR_TESSERACT_EXECUTABLE; A3S Use will not install it automatically.", + ) +} + +fn missing_vision() -> UseError { + UseError::new( + "use.ocr.provider_missing", + "The vision OCR provider is not configured.", + ) + .with_suggestion( + "Set A3S_OCR_VISION_MODEL and an approved HTTPS endpoint/API key before sending source images to a vision provider.", + ) +} + +fn is_loopback(url: &Url) -> bool { + matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) +} + +fn redacted_endpoint(url: &Url) -> String { + let mut redacted = url.clone(); + redacted.set_query(None); + redacted.set_fragment(None); + redacted.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_insecure_remote_vision_endpoint() { + let endpoint = Url::parse("http://ocr.example.com/v1/").unwrap(); + let error = validate_endpoint(&endpoint, Some("secret")).unwrap_err(); + assert_eq!(error.code, "use.ocr.vision_config_invalid"); + } + + #[test] + fn permits_loopback_http_without_an_api_key() { + let endpoint = Url::parse("http://127.0.0.1:8080/v1/").unwrap(); + validate_endpoint(&endpoint, None).unwrap(); + } +} diff --git a/crates/office/skills/a3s-use-office/SKILL.md b/crates/office/skills/a3s-use-office/SKILL.md index 6fe1056f..a608ae12 100644 --- a/crates/office/skills/a3s-use-office/SKILL.md +++ b/crates/office/skills/a3s-use-office/SKILL.md @@ -9,10 +9,27 @@ Use A3S Use as the application boundary for Office documents. Prefer the in-process native engine and its typed operations. Use the compatibility route only when the requested operation is not yet native. +Use the host surface that is already available: + +- In an A3S Code `use` worker, call the available + `mcp__use_office__*` tools directly. The host has already started the native + MCP server and owns its lifecycle; do not run shell commands or install a + provider. +- If a requested operation is absent from the native tools, use an available + `mcp__use_office_compat__*` tool only as an explicit compatibility fallback. + If that route is absent, report the missing capability instead of installing, + repairing, or falling back to a shell. +- In a CLI-only agent host, use the `a3s use office native ...` commands below. + ## Workflow 1. Identify and inspect the document before changing it. + In an A3S Code `use` worker, begin with + `mcp__use_office__office_validate`, then open a session and use + `mcp__use_office__office_view`, `office_get`, or `office_query` as needed. + In a CLI-only host, use: + ```bash a3s use office native validate "$FILE" --json a3s use office native view "$FILE" annotated --limit 200 --json @@ -20,7 +37,9 @@ only when the requested operation is not yet native. a3s use office native view "$FILE" issues --json ``` -2. Load the format reference relevant to the task: +2. In a CLI-only agent host, load the format reference relevant to the task. + An A3S Code `use` worker cannot read Skill reference files; rely on this + guidance and the available MCP tool schemas instead. - Read [references/word.md](references/word.md) for `.docx`. - Read [references/spreadsheet.md](references/spreadsheet.md) for `.xlsx`. @@ -52,6 +71,10 @@ when an agent must bound its lifetime. ## Choose the Surface +- In an A3S Code `use` worker, use `mcp__use_office__*` and keep the returned + Office session ID stable until the document is saved and closed. +- Use `mcp__use_office_compat__*` only when the native route lacks the requested + operation and the compatibility tools are actually present. - Use `a3s use office native ... --json` for local automation and scripts. - Use `a3s use mcp serve office-native` for typed, stateful agent sessions. Read [references/mcp.md](references/mcp.md) before using its session tools. diff --git a/crates/office/skills/a3s-use-office/references/mcp.md b/crates/office/skills/a3s-use-office/references/mcp.md index 5d1da61b..cb78ce1e 100644 --- a/crates/office/skills/a3s-use-office/references/mcp.md +++ b/crates/office/skills/a3s-use-office/references/mcp.md @@ -8,7 +8,11 @@ ## Session Workflow -Start the explicit native standard MCP server: +In an A3S Code `use` worker, the host has already started the native server. +Call the available `mcp__use_office__office_*` tools; do not start a process or +run a shell command. Tool names below omit the host prefix for readability. + +In a CLI-only MCP host, start the explicit native standard MCP server: ```bash a3s use mcp serve office-native @@ -504,6 +508,9 @@ Annotated reads include unsaved mutations in the current typed session. Screenshot output requires a no-clobber `.png` path and a ready A3S Browser provider; other native Office tools do not require Browser or OfficeCLI. -Use `a3s use mcp serve office` only for the pinned OfficeCLI compatibility -server. It is a separate standard MCP target and is not the native session -engine. +In an A3S Code `use` worker, use an available +`mcp__use_office_compat__*` tool only when the native vocabulary lacks the +requested operation. In a CLI-only MCP host, `a3s use mcp serve office-compat` +starts the pinned OfficeCLI compatibility server; the legacy +`a3s use mcp serve office` alias remains supported. It is a separate standard +MCP target and is not the native session engine. diff --git a/docs/architecture.md b/docs/architecture.md index c152127b..c165d7b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,6 +37,15 @@ The package manifest is a3s-use-extension.acl and is parsed by a3s-acl. A3S Use owns identity, routes, trust, activation, and lifecycle around the surfaces. It does not define JSON-RPC methods or convert surfaces implicitly. +`a3s-use-ocr` implements the reserved first-party `ocr` route in the default +Use build. The release packages its content-bound Skill and exposes the native +CLI plus standard stdio MCP without a separate extension install. The process +accepts bounded local image files and binds every result to the canonical +source digest. It uses only an explicitly present Tesseract executable or +configured vision endpoint; it never installs a provider silently. The vision +diagnostic discloses off-device image transfer, and its MCP extraction tool +carries conservative open-world annotations. + ## Hot-plug registry Extension code remains behind native process boundaries. The registry is a @@ -64,8 +73,8 @@ custom RPC protocol, `dlopen`, or restart is required. ### Unified capability projection Resident Code hosts do not need separate discovery paths for built-in and -external domains. `capability snapshot` projects Browser, Office, Box, and -enabled extensions through one schema while preserving each binding's +external domains. `capability snapshot` projects Browser, native Office, OCR, +Box, and enabled extensions through one schema while preserving each binding's `built-in` or `extension` origin. `capability watch` accepts both the extension generation and a content revision. The generation advances for extension lifecycle commits; the SHA-256 revision also detects built-in provider @@ -73,10 +82,15 @@ readiness and packaged Skill changes when the extension generation remains unchanged. Each Skill projection includes an absolute package path and its own lowercase SHA-256, allowing a resident host to reject raced or modified bytes before replacing its live Skill. -The default distribution projects both the Browser Skill and the first-party -`a3s-use-office` Skill. `office skills list|get|path` exposes the latter as -bounded local CLI reads; it never launches the OfficeCLI compatibility -provider. +The default distribution projects the Browser, first-party `a3s-use-office`, +and first-party `a3s-use-ocr` Skills. `office skills list|get|path` exposes the +Office Skill as bounded local CLI reads; it never launches the OfficeCLI +compatibility provider. For resident hosts, `use/office` targets the built-in +`office-native` MCP server and is ready independently of OfficeCLI. A discovered +OfficeCLI provider is projected separately as `use/office-compat`, targeting +the standard compatibility server without carrying the native Skill. The +`use/ocr` route targets `ocr-native`; provider readiness remains explicit and +never triggers a silent Tesseract or vision-provider install. The projection contains content-bound Skill references and an MCP launch target, never executable extension code or a generic action payload. Consumers still @@ -361,11 +375,13 @@ merged-span rewriting fail before save; Presentation table merge editing remains outside this bounded milestone. These mutations use the existing typed batch transaction and do not introduce another protocol or runtime. -Unpromoted commands are delegated to OfficeCLI and `mcp serve office` launches -its standard MCP server. That compatibility process remains isolated from the +Unpromoted commands are delegated to OfficeCLI and +`mcp serve office-compat` launches its standard MCP server; `mcp serve office` +remains a legacy alias. That compatibility process remains isolated from the native engine. `mcp serve office-native` instead runs the A3S-owned server in -process, never discovers or starts OfficeCLI, and keeps the compatibility target -unchanged until the native product gates pass. +process and never discovers or starts OfficeCLI. Resident capability projection +uses the native target canonically and advertises compatibility as a distinct +optional route. The preview MCP adapter has an explicit typed vocabulary rather than a command string passthrough. It supports validate, create/open/list, semantic get/query, @@ -417,7 +433,7 @@ component for one deprecation cycle before removal. Implemented: -1. Core, Browser, Office, extension, and component contracts. +1. Core, Browser, Office, OCR, extension, and component contracts. 2. Chrome and Lightpanda extraction from Search. 3. Search injection through `Arc`. 4. Typed Browser rendering and session tools over standard MCP stdio. @@ -479,6 +495,11 @@ Implemented: 15. A packaged first-party `a3s-use-office` Skill with progressive Word/Spreadsheet/Presentation/MCP references, bounded local discovery, release-archive smoke checks, and content-bound capability projection. +16. A first-party built-in OCR route with typed provider diagnostics, bounded + image admission, source SHA-256 evidence, local Tesseract and explicit + vision adapters, standard MCP annotations/output schemas, and a + release-packaged content-bound Skill that projects to `mcp__use_ocr__*` in + A3S Code. Next: diff --git a/src/capability_registry.rs b/src/capability_registry.rs index 92b57573..b8b02f27 100644 --- a/src/capability_registry.rs +++ b/src/capability_registry.rs @@ -76,6 +76,8 @@ pub(crate) async fn snapshot() -> UseResult { let mut capabilities = vec![ browser_capability().await?, office_capability().await?, + office_compatibility_capability(), + ocr_capability().await?, box_capability(), ]; capabilities.extend(extensions); @@ -167,26 +169,30 @@ async fn browser_capability() -> UseResult { async fn office_capability() -> UseResult { #[cfg(feature = "office")] { - let diagnostic = a3s_use_office::doctor(); - let ready = diagnostic.readiness == Readiness::Ready; let skill = crate::office_skills::primary_skill_surface().await; let (package_root, skills) = match skill { Some((root, path)) => (Some(root), vec![skill_surface(path).await?]), None => (None, Vec::new()), }; + let mut surfaces = vec!["cli".to_string(), "skill".to_string()]; + #[cfg(feature = "mcp")] + surfaces.push("mcp".to_string()); Ok(CapabilityBinding { id: "use/office".to_string(), route: "office".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), origin: CapabilityOrigin::BuiltIn, enabled: true, - readiness: diagnostic.readiness, + readiness: Readiness::Ready, package_root, - surfaces: vec!["cli".to_string(), "mcp".to_string(), "skill".to_string()], - mcp: ready.then(|| McpSurface { - target: "office".to_string(), + surfaces, + #[cfg(feature = "mcp")] + mcp: Some(McpSurface { + target: "office-native".to_string(), transport: McpTransport::Stdio, }), + #[cfg(not(feature = "mcp"))] + mcp: None, skills, }) } @@ -207,6 +213,95 @@ async fn office_capability() -> UseResult { } } +fn office_compatibility_capability() -> CapabilityBinding { + #[cfg(feature = "office")] + { + let diagnostic = a3s_use_office::doctor(); + let ready = diagnostic.readiness == Readiness::Ready; + CapabilityBinding { + id: "use/office-compat".to_string(), + route: "office-compat".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + origin: CapabilityOrigin::BuiltIn, + enabled: true, + readiness: diagnostic.readiness, + package_root: None, + surfaces: vec!["mcp".to_string()], + mcp: ready.then(|| McpSurface { + target: "office-compat".to_string(), + transport: McpTransport::Stdio, + }), + skills: Vec::new(), + } + } + #[cfg(not(feature = "office"))] + { + CapabilityBinding { + id: "use/office-compat".to_string(), + route: "office-compat".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + origin: CapabilityOrigin::BuiltIn, + enabled: false, + readiness: Readiness::Missing, + package_root: None, + surfaces: Vec::new(), + mcp: None, + skills: Vec::new(), + } + } +} + +async fn ocr_capability() -> UseResult { + #[cfg(feature = "ocr")] + { + let diagnostic = crate::ocr_builtin::diagnostic(); + let skill = crate::ocr_builtin::primary_skill_surface().await; + let (package_root, skills) = match skill { + Some((root, path)) => (Some(root), vec![skill_surface(path).await?]), + None => (None, Vec::new()), + }; + let mut surfaces = vec!["cli".to_string()]; + if !skills.is_empty() { + surfaces.push("skill".to_string()); + } + #[cfg(feature = "mcp")] + surfaces.push("mcp".to_string()); + Ok(CapabilityBinding { + id: "use/ocr".to_string(), + route: "ocr".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + origin: CapabilityOrigin::BuiltIn, + enabled: true, + readiness: diagnostic.readiness, + package_root, + surfaces, + #[cfg(feature = "mcp")] + mcp: Some(McpSurface { + target: "ocr-native".to_string(), + transport: McpTransport::Stdio, + }), + #[cfg(not(feature = "mcp"))] + mcp: None, + skills, + }) + } + #[cfg(not(feature = "ocr"))] + { + Ok(CapabilityBinding { + id: "use/ocr".to_string(), + route: "ocr".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + origin: CapabilityOrigin::BuiltIn, + enabled: false, + readiness: Readiness::Missing, + package_root: None, + surfaces: Vec::new(), + mcp: None, + skills: Vec::new(), + }) + } +} + fn box_capability() -> CapabilityBinding { let diagnostic = crate::component_route::box_diagnostic(); CapabilityBinding { @@ -310,6 +405,13 @@ async fn project_extensions( ) -> UseResult>> { let mut capabilities = Vec::with_capacity(snapshot.routes.len()); for route in &snapshot.routes { + #[cfg(feature = "ocr")] + if route.route == "ocr" { + // OCR became a first-party built-in route. Ignore a legacy OCR + // extension receipt so an older installation cannot shadow or + // duplicate the release-matched built-in MCP/Skill projection. + continue; + } let Some(extension) = crate::extension_host::get(&route.package_id).await? else { return Ok(None); }; @@ -379,9 +481,21 @@ mod tests { .iter() .find(|capability| capability.id == "use/office") .unwrap(); + let office_compat = snapshot + .capabilities + .iter() + .find(|capability| capability.id == "use/office-compat") + .unwrap(); + let ocr = snapshot + .capabilities + .iter() + .find(|capability| capability.id == "use/ocr") + .unwrap(); assert_eq!(browser.origin, CapabilityOrigin::BuiltIn); assert_eq!(office.origin, CapabilityOrigin::BuiltIn); + assert_eq!(office_compat.origin, CapabilityOrigin::BuiltIn); + assert_eq!(ocr.origin, CapabilityOrigin::BuiltIn); #[cfg(feature = "browser")] { assert!(browser.surfaces.iter().any(|surface| surface == "skill")); @@ -405,6 +519,13 @@ mod tests { .iter() .any(|skill| skill.path.ends_with("a3s-use-office/SKILL.md"))); assert!(office.skills.iter().all(|skill| skill.sha256.len() == 64)); + #[cfg(feature = "mcp")] + assert_eq!( + office.mcp.as_ref().map(|surface| surface.target.as_str()), + Some("office-native") + ); + assert!(office_compat.skills.is_empty()); + assert_eq!(office_compat.route, "office-compat"); } #[cfg(not(feature = "office"))] { @@ -412,6 +533,27 @@ mod tests { assert!(office.surfaces.is_empty()); assert!(office.skills.is_empty()); } + #[cfg(feature = "ocr")] + { + assert!(ocr.enabled); + assert!(ocr.surfaces.iter().any(|surface| surface == "skill")); + assert!(ocr + .skills + .iter() + .any(|skill| skill.path.ends_with("a3s-use-ocr/SKILL.md"))); + assert!(ocr.skills.iter().all(|skill| skill.sha256.len() == 64)); + #[cfg(feature = "mcp")] + assert_eq!( + ocr.mcp.as_ref().map(|surface| surface.target.as_str()), + Some("ocr-native") + ); + } + #[cfg(not(feature = "ocr"))] + { + assert!(!ocr.enabled); + assert!(ocr.surfaces.is_empty()); + assert!(ocr.skills.is_empty()); + } assert_eq!(snapshot.revision.len(), 64); } diff --git a/src/cli.rs b/src/cli.rs index 07c6ad2a..23bad00f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -63,6 +63,7 @@ pub async fn run(args: Vec) -> UseResult { "doctor" => doctor(args.get(1).map(String::as_str)), "component" => component(&args[1..]).await, "browser" => browser(&args[1..]).await, + "ocr" => ocr(&args[1..]).await, "box" => { let exit_code = crate::component_route::run_box(&args[1..]).await?; Ok(CommandOutput::delegated(exit_code)) @@ -90,6 +91,9 @@ fn version() -> CommandOutput { "schemaVersion": 1, "ok": true, "version": env!("CARGO_PKG_VERSION"), + "data": { + "version": env!("CARGO_PKG_VERSION"), + }, }), exit_code: 0, should_print: true, @@ -104,7 +108,7 @@ fn help() -> CommandOutput { " a3s-use capabilities [--json]\n", " a3s-use capability snapshot [--json]\n", " a3s-use capability watch [--after-generation ] [--after-revision ] [--timeout-ms ] [--json]\n", - " a3s-use doctor [browser|box|office] [--json]\n", + " a3s-use doctor [browser|box|office|ocr] [--json]\n", " a3s-use component list|status|install|uninstall [args] [--json]\n", " a3s-use browser doctor [--json]\n", " a3s-use browser render [--output ] [--screenshot ] [--json]\n", @@ -114,12 +118,14 @@ fn help() -> CommandOutput { " a3s-use office skills list|get|path [args] [--json]\n", " a3s-use office native get|query|view|watch|raw|raw-set|dump|merge|validate|create|add|add-part|set|sort|remove|move|copy|swap|insert-rows|delete-rows|insert-columns|delete-columns|rename-sheet|move-sheet|copy-sheet|batch [args] [--json]\n", " a3s-use office \n", + " a3s-use ocr doctor [--json]\n", + " a3s-use ocr extract [--language ] [--provider ] [--json]\n", " a3s-use extension list|inspect|doctor [args] [--json]\n", " a3s-use extension enable [--json]\n", " a3s-use extension disable [--timeout-ms ] [--json]\n", " a3s-use extension snapshot|watch [--after-generation ] [--timeout-ms ] [--json]\n", " a3s-use mcp serve browser [--tools ]\n", - " a3s-use mcp serve office|office-native|\n", + " a3s-use mcp serve office|office-native|office-compat|ocr|\n", " a3s-use mcp start|status|stop [browser] [--json]" ), serde_json::json!({ @@ -131,6 +137,7 @@ fn help() -> CommandOutput { "browser", "box", "office", + "ocr", "extension", "mcp" ] @@ -142,9 +149,10 @@ async fn capabilities() -> UseResult { let browser = browser_diagnostic(); let box_domain = crate::component_route::box_diagnostic(); let office = office_diagnostic(); + let ocr = ocr_diagnostic(); let (extension_generation, extensions) = extension_capabilities().await?; Ok(CommandOutput::success( - "Built-in routes: browser, box, office", + "Built-in routes: browser, box, office, ocr", serde_json::json!({ "domains": [ { @@ -159,6 +167,12 @@ async fn capabilities() -> UseResult { "readiness": office.readiness, "surfaces": ["cli", "mcp", "skill"] }, + { + "id": "ocr", + "builtIn": true, + "readiness": ocr.readiness, + "surfaces": ["cli", "mcp", "skill"] + }, { "id": "box", "builtIn": true, @@ -221,11 +235,13 @@ fn doctor(domain: Option<&str>) -> UseResult { None | Some("--json") => vec![ browser_diagnostic(), office_diagnostic(), + ocr_diagnostic(), crate::component_route::box_diagnostic(), ], Some("browser") => vec![browser_diagnostic()], Some("box") => vec![crate::component_route::box_diagnostic()], Some("office") => vec![office_diagnostic()], + Some("ocr") => vec![ocr_diagnostic()], Some(value) => { return Err(UseError::new( "use.domain_unknown", @@ -267,8 +283,9 @@ async fn component_list() -> UseResult { let browser = component_value("browser", &browser_diagnostic()); let box_component = component_value("box", &crate::component_route::box_diagnostic()); let office = component_value("office", &office_diagnostic()); + let ocr = component_value("ocr", &ocr_diagnostic()); let extensions = installed_extensions().await?; - let mut components = vec![browser, box_component, office]; + let mut components = vec![browser, box_component, office, ocr]; components.extend( extensions .iter() @@ -278,6 +295,7 @@ async fn component_list() -> UseResult { "browser".to_string(), "box".to_string(), "office".to_string(), + "ocr".to_string(), ]; human.extend( extensions @@ -495,7 +513,10 @@ async fn component_uninstall(id: &str) -> UseResult { )); } } - if matches!(id, "browser" | "use/browser" | "office" | "use/office") { + if matches!( + id, + "browser" | "use/browser" | "office" | "use/office" | "ocr" | "use/ocr" + ) { return Ok(CommandOutput::success( format!("No managed runtime files are owned for '{id}'."), serde_json::json!({ @@ -663,9 +684,11 @@ async fn mcp(args: &[String]) -> UseResult { "Standard Browser MCP support is disabled in this custom build.", )) } - "office" | "use/office" => { + "office" | "use/office" | "office-compat" | "use/office-compat" => { if args.len() != 2 { - return Err(usage_error("mcp serve office accepts exactly one target")); + return Err(usage_error( + "mcp serve office compatibility targets accept exactly one target", + )); } #[cfg(feature = "office")] { @@ -696,6 +719,21 @@ async fn mcp(args: &[String]) -> UseResult { "Native Office MCP support is disabled in this custom build.", )) } + "ocr" | "use/ocr" | "ocr-native" | "use/ocr-native" => { + if args.len() != 2 { + return Err(usage_error("mcp serve ocr accepts exactly one target")); + } + #[cfg(all(feature = "ocr", feature = "mcp"))] + { + a3s_use_ocr::OcrMcpServer::from_env()?.serve_stdio().await?; + Ok(CommandOutput::delegated(0)) + } + #[cfg(not(all(feature = "ocr", feature = "mcp")))] + Err(UseError::new( + "use.mcp.disabled", + "OCR MCP support is disabled in this custom build.", + )) + } package_id if external_package_id(package_id).is_some() => { if args.len() != 2 { return Err(usage_error( @@ -888,6 +926,7 @@ fn builtin_diagnostic(id: &str) -> Option { "browser" | "use/browser" => Some(browser_diagnostic()), "box" | "use/box" => Some(crate::component_route::box_diagnostic()), "office" | "use/office" => Some(office_diagnostic()), + "ocr" | "use/ocr" => Some(ocr_diagnostic()), _ => None, } } @@ -1031,7 +1070,21 @@ fn office_diagnostic() -> DomainDiagnostic { disabled_diagnostic("office") } -#[cfg(any(not(feature = "browser"), not(feature = "office")))] +#[cfg(feature = "ocr")] +fn ocr_diagnostic() -> DomainDiagnostic { + crate::ocr_builtin::diagnostic() +} + +#[cfg(not(feature = "ocr"))] +fn ocr_diagnostic() -> DomainDiagnostic { + disabled_diagnostic("ocr") +} + +#[cfg(any( + not(feature = "browser"), + not(feature = "office"), + not(feature = "ocr") +))] fn disabled_diagnostic(domain: &str) -> DomainDiagnostic { DomainDiagnostic { domain: domain.to_string(), @@ -1044,6 +1097,25 @@ fn disabled_diagnostic(domain: &str) -> DomainDiagnostic { } } +#[cfg(feature = "ocr")] +async fn ocr(args: &[String]) -> UseResult { + let output = a3s_use_ocr::cli::run(args.to_vec()).await?; + Ok(CommandOutput { + human: output.human, + json: output.json, + exit_code: output.exit_code, + should_print: output.should_print, + }) +} + +#[cfg(not(feature = "ocr"))] +async fn ocr(_args: &[String]) -> UseResult { + Err(UseError::new( + "use.ocr.disabled", + "OCR support is disabled in this custom build.", + )) +} + fn value_argument<'a>(args: &'a [String], index: usize, message: &str) -> UseResult<&'a str> { args.get(index) .map(String::as_str) diff --git a/src/cli_tests.rs b/src/cli_tests.rs index 6aa99587..e60610cd 100644 --- a/src/cli_tests.rs +++ b/src/cli_tests.rs @@ -1,13 +1,25 @@ use super::*; #[tokio::test] -async fn capabilities_always_include_browser_and_office() { +async fn version_json_exposes_a_typed_data_payload_for_consumers() { + let output = run(vec!["--version".to_string(), "--json".to_string()]) + .await + .unwrap(); + + assert_eq!(output.json["schemaVersion"], 1); + assert_eq!(output.json["ok"], true); + assert_eq!(output.json["data"]["version"], env!("CARGO_PKG_VERSION")); +} + +#[tokio::test] +async fn capabilities_always_include_browser_office_and_ocr() { let output = run(vec!["capabilities".to_string(), "--json".to_string()]) .await .unwrap(); let domains = output.json["data"]["domains"].as_array().unwrap(); assert_eq!(domains[0]["id"], "browser"); assert_eq!(domains[1]["id"], "office"); + assert_eq!(domains[2]["id"], "ocr"); assert!(domains[0]["surfaces"] .as_array() .unwrap() @@ -35,9 +47,14 @@ async fn capability_snapshot_unifies_built_ins_without_rpc_envelopes() { .iter() .find(|capability| capability["id"] == "use/office") .unwrap(); + let ocr = capabilities + .iter() + .find(|capability| capability["id"] == "use/ocr") + .unwrap(); assert_eq!(browser["origin"], "built-in"); assert_eq!(office["origin"], "built-in"); + assert_eq!(ocr["origin"], "built-in"); #[cfg(feature = "office")] { assert!(office["surfaces"] @@ -60,10 +77,41 @@ async fn capability_snapshot_unifies_built_ins_without_rpc_envelopes() { assert_eq!(office["surfaces"], serde_json::json!([])); assert!(office.get("skills").is_none()); } + #[cfg(feature = "ocr")] + { + assert_eq!(ocr["enabled"], true); + assert_eq!(ocr["mcp"]["target"], "ocr-native"); + assert!(ocr["skills"][0]["path"].as_str().is_some_and( + |path| std::path::Path::new(path).ends_with("skills/a3s-use-ocr/SKILL.md") + )); + assert_eq!(ocr["skills"][0]["sha256"].as_str().unwrap().len(), 64); + } + #[cfg(not(feature = "ocr"))] + { + assert_eq!(ocr["enabled"], false); + assert_eq!(ocr["surfaces"], serde_json::json!([])); + assert!(ocr.get("skills").is_none()); + } assert_eq!(registry["revision"].as_str().unwrap().len(), 64); assert!(output.json.get("jsonrpc").is_none()); } +#[cfg(feature = "ocr")] +#[tokio::test] +async fn built_in_ocr_doctor_uses_the_root_cli_contract() { + let output = run(vec![ + "ocr".to_string(), + "doctor".to_string(), + "--json".to_string(), + ]) + .await + .unwrap(); + + assert_eq!(output.json["schemaVersion"], 1); + assert_eq!(output.json["ok"], true); + assert!(output.json["data"]["readiness"].is_string()); +} + #[tokio::test] async fn component_status_uses_cli_json_contract() { let output = run(vec![ diff --git a/src/lib.rs b/src/lib.rs index 2c27ad51..fe1d9d97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,9 @@ pub mod cli; mod component_route; mod extension_cli; +#[cfg(feature = "ocr")] +mod ocr_builtin; + #[cfg(feature = "office")] mod office_artifact; #[cfg(feature = "office")] @@ -37,5 +40,8 @@ pub use a3s_use_browser as browser; #[cfg(feature = "office")] pub use a3s_use_office as office; +#[cfg(feature = "ocr")] +pub use a3s_use_ocr as ocr; + #[cfg(feature = "extensions")] pub use a3s_use_extension as extension; diff --git a/src/mcp.rs b/src/mcp.rs index 5c4c090c..68604713 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -210,7 +210,13 @@ mod browser { impl BrowserMcpServer { #[tool( name = "browser_doctor", - description = "Inspect the locally available A3S Use Browser provider without installing software" + description = "Inspect the locally available A3S Use Browser provider without installing software", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) )] async fn browser_doctor(&self) -> Result { Ok(match serde_json::to_value(a3s_use_browser::doctor()) { @@ -224,7 +230,13 @@ mod browser { #[tool( name = "browser_render", - description = "Render one web page with the configured local Browser provider" + description = "Render one web page with the configured local Browser provider", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) )] async fn browser_render( &self, @@ -249,7 +261,13 @@ mod browser { #[tool( name = "browser_open", - description = "Open an isolated stateful Browser session and return its first semantic snapshot" + description = "Open an isolated stateful Browser session and return its first semantic snapshot", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) )] async fn browser_open( &self, @@ -281,7 +299,13 @@ mod browser { #[tool( name = "browser_list", - description = "List open Browser sessions and their current URLs" + description = "List open Browser sessions and their current URLs", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) )] async fn browser_list(&self) -> Result { Ok(tool_result(self.sessions.list().await)) @@ -289,7 +313,13 @@ mod browser { #[tool( name = "browser_navigate", - description = "Navigate an open Browser session and return a fresh semantic snapshot" + description = "Navigate an open Browser session and return a fresh semantic snapshot", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) )] async fn browser_navigate( &self, @@ -320,7 +350,13 @@ mod browser { #[tool( name = "browser_snapshot", - description = "Return a compact semantic snapshot and fresh @e element references" + description = "Return a compact semantic snapshot and fresh @e element references", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) )] async fn browser_snapshot( &self, @@ -335,7 +371,13 @@ mod browser { #[tool( name = "browser_click", - description = "Click an element reference from the latest semantic snapshot" + description = "Click an element reference from the latest semantic snapshot", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) )] async fn browser_click( &self, @@ -352,7 +394,13 @@ mod browser { #[tool( name = "browser_type", - description = "Focus an element reference and type text into it" + description = "Focus an element reference and type text into it", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) )] async fn browser_type( &self, @@ -371,7 +419,13 @@ mod browser { #[tool( name = "browser_press", - description = "Focus an element reference and press one keyboard key" + description = "Focus an element reference and press one keyboard key", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) )] async fn browser_press( &self, @@ -390,7 +444,13 @@ mod browser { #[tool( name = "browser_select", - description = "Select an option value on a referenced select element" + description = "Select an option value on a referenced select element", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) )] async fn browser_select( &self, @@ -409,7 +469,13 @@ mod browser { #[tool( name = "browser_scroll", - description = "Scroll the current page by explicit horizontal and vertical deltas" + description = "Scroll the current page by explicit horizontal and vertical deltas", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) )] async fn browser_scroll( &self, @@ -426,7 +492,13 @@ mod browser { #[tool( name = "browser_screenshot", - description = "Capture a full-page PNG from an open Browser session to an explicit local path" + description = "Capture a full-page PNG from an open Browser session to an explicit local path", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) )] async fn browser_screenshot( &self, @@ -449,7 +521,13 @@ mod browser { #[tool( name = "browser_close", - description = "Close one Browser session and release its tab resources" + description = "Close one Browser session and release its tab resources", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) )] async fn browser_close( &self, @@ -470,7 +548,13 @@ mod browser { #[tool( name = "browser_service_stop", - description = "Stop the authenticated persistent A3S Use Browser MCP deployment" + description = "Stop the authenticated persistent A3S Use Browser MCP deployment", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) )] async fn browser_service_stop(&self) -> Result { let Some(shutdown) = self.shutdown.clone() else { @@ -564,7 +648,7 @@ mod browser { None, ); let tools = server.tool_router.list_all(); - let mut names = tools + let mut names: Vec<&str> = tools .iter() .map(|tool| tool.name.as_ref()) .collect::>(); @@ -587,6 +671,23 @@ mod browser { "browser_type" ] ); + + let annotations = |name: &str| { + tools + .iter() + .find(|tool| tool.name == name) + .and_then(|tool| tool.annotations.as_ref()) + .unwrap_or_else(|| panic!("{name} must declare MCP annotations")) + }; + let list = annotations("browser_list"); + assert_eq!(list.read_only_hint, Some(true)); + assert_eq!(list.open_world_hint, Some(false)); + let render = annotations("browser_render"); + assert_eq!(render.read_only_hint, Some(true)); + assert_eq!(render.open_world_hint, Some(true)); + let click = annotations("browser_click"); + assert_eq!(click.read_only_hint, Some(false)); + assert_eq!(click.open_world_hint, Some(true)); } #[test] diff --git a/src/mcp/office.rs b/src/mcp/office.rs index 1b84ff54..3305ff59 100644 --- a/src/mcp/office.rs +++ b/src/mcp/office.rs @@ -86,7 +86,13 @@ impl NativeOfficeMcpServer { impl NativeOfficeMcpServer { #[tool( name = "office_validate", - description = "Validate and identify one local OOXML document without opening a session" + description = "Validate and identify one local OOXML document without opening a session", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) )] async fn office_validate( &self, @@ -108,7 +114,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_create", - description = "Create a blank native OOXML document and register a mutable in-memory session" + description = "Create a blank native OOXML document and register a mutable in-memory session", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) )] async fn office_create( &self, @@ -125,7 +137,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_open", - description = "Open a local OOXML document in a bounded native in-memory session" + description = "Open a local OOXML document in a bounded native in-memory session", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) )] async fn office_open( &self, @@ -145,7 +163,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_list", - description = "List native Office sessions owned by this MCP server process" + description = "List native Office sessions owned by this MCP server process", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) )] async fn office_list(&self) -> Result { let mut entries = self.sessions.list().await; @@ -165,7 +189,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_get", - description = "Read one stable semantic path from an open native Office session" + description = "Read one stable semantic path from an open native Office session", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) )] async fn office_get( &self, @@ -192,7 +222,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_query", - description = "Run a native semantic selector with a bounded result count" + description = "Run a native semantic selector with a bounded result count", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) )] async fn office_query( &self, @@ -228,7 +264,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_view", - description = "Produce a native text, bounded annotated, outline, statistics, bounded issues, standalone all-format HTML or SVG, or Browser-injected PNG screenshot view for an open session" + description = "Produce a native text, bounded annotated, outline, statistics, bounded issues, standalone all-format HTML or SVG, or Browser-injected PNG screenshot view for an open session", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) )] async fn office_view( &self, @@ -303,7 +345,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_raw_xml", - description = "Inspect one existing OOXML XML part, limited to 1 MiB of original bytes" + description = "Inspect one existing OOXML XML part, limited to 1 MiB of original bytes", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) )] async fn office_raw_xml( &self, @@ -332,7 +380,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_apply_batch", - description = "Apply a bounded typed mutation batch atomically in memory; call office_save to persist it" + description = "Apply a bounded typed mutation batch atomically in memory; call office_save to persist it", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) )] async fn office_apply_batch( &self, @@ -363,7 +417,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_merge_template", - description = "Merge bounded JSON data into a cloned session document and atomically save a distinct output" + description = "Merge bounded JSON data into a cloned session document and atomically save a distinct output", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) )] async fn office_merge_template( &self, @@ -403,7 +463,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_save", - description = "Atomically persist one mutable native Office session, optionally to a new path" + description = "Atomically persist one mutable native Office session, optionally to a new path", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = false + ) )] async fn office_save( &self, @@ -430,7 +496,13 @@ impl NativeOfficeMcpServer { #[tool( name = "office_close", - description = "Close a native Office session, refusing unsaved changes unless discard is explicit" + description = "Close a native Office session, refusing unsaved changes unless discard is explicit", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) )] async fn office_close( &self, diff --git a/src/mcp/office/tests.rs b/src/mcp/office/tests.rs index 10827174..8e70566a 100644 --- a/src/mcp/office/tests.rs +++ b/src/mcp/office/tests.rs @@ -14,7 +14,7 @@ use a3s_use_office::{ fn native_office_server_exposes_only_bounded_typed_tools() { let server = NativeOfficeMcpServer::new(); let tools = server.tool_router.list_all(); - let mut names = tools + let mut names: Vec<&str> = tools .iter() .map(|tool| tool.name.as_ref()) .collect::>(); @@ -36,6 +36,30 @@ fn native_office_server_exposes_only_bounded_typed_tools() { "office_view", ] ); + + let annotations = |name: &str| { + tools + .iter() + .find(|tool| tool.name == name) + .and_then(|tool| tool.annotations.as_ref()) + .unwrap_or_else(|| panic!("{name} must declare MCP annotations")) + }; + for name in [ + "office_validate", + "office_list", + "office_get", + "office_query", + "office_raw_xml", + ] { + let annotation = annotations(name); + assert_eq!(annotation.read_only_hint, Some(true), "{name}"); + assert_eq!(annotation.open_world_hint, Some(false), "{name}"); + } + assert_eq!( + annotations("office_apply_batch").read_only_hint, + Some(false) + ); + assert_eq!(annotations("office_save").destructive_hint, Some(true)); } #[test] diff --git a/src/ocr_builtin.rs b/src/ocr_builtin.rs new file mode 100644 index 00000000..127004c2 --- /dev/null +++ b/src/ocr_builtin.rs @@ -0,0 +1,95 @@ +//! Built-in projection glue for the first-party OCR domain. + +use std::path::{Path, PathBuf}; + +use a3s_use_core::{DomainDiagnostic, Readiness}; +use a3s_use_ocr::{OcrClient, OcrProviderKind}; + +pub(crate) fn diagnostic() -> DomainDiagnostic { + match OcrClient::from_env() { + Ok(client) => { + let diagnostic = client.diagnostic(); + DomainDiagnostic { + domain: "ocr".to_string(), + readiness: diagnostic.readiness, + provider: diagnostic.provider.map(provider_name).map(str::to_string), + version: None, + path: diagnostic.executable, + message: diagnostic.message, + suggestions: diagnostic.suggestions, + } + } + Err(error) => DomainDiagnostic { + domain: "ocr".to_string(), + readiness: Readiness::Broken, + provider: None, + version: None, + path: None, + message: error.message, + suggestions: error.suggestion.into_iter().collect(), + }, + } +} + +pub(crate) async fn primary_skill_surface() -> Option<(PathBuf, PathBuf)> { + let mut roots = Vec::new(); + if let Some(root) = std::env::var_os("A3S_USE_OCR_SKILLS_DIR").map(PathBuf::from) { + roots.push(root); + } + if let Ok(executable) = std::env::current_exe() { + if let Some(parent) = executable.parent() { + roots.push(parent.join("ocr-skills")); + } + } + roots.push( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("crates") + .join("ocr") + .join("skills"), + ); + + for root in roots { + let skill = root.join("a3s-use-ocr/SKILL.md"); + let Ok(root) = tokio::fs::canonicalize(root).await else { + continue; + }; + let Ok(skill) = tokio::fs::canonicalize(skill).await else { + continue; + }; + if skill.starts_with(&root) { + return Some((root, skill)); + } + } + None +} + +fn provider_name(provider: OcrProviderKind) -> &'static str { + match provider { + OcrProviderKind::Auto => "auto", + OcrProviderKind::Tesseract => "tesseract", + OcrProviderKind::Vision => "vision", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn source_skill_is_available_to_development_builds() { + let (root, skill) = primary_skill_surface().await.unwrap(); + assert!(root.is_absolute()); + assert!(skill.starts_with(root)); + assert!(skill.ends_with("a3s-use-ocr/SKILL.md")); + } + + #[test] + fn diagnostic_is_typed_even_without_a_provider() { + let diagnostic = diagnostic(); + assert_eq!(diagnostic.domain, "ocr"); + assert!(matches!( + diagnostic.readiness, + Readiness::Ready | Readiness::Missing | Readiness::Broken + )); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 3bdb785f..f502594b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -21,9 +21,13 @@ fn capabilities_are_available_as_versioned_json() { assert!(output.status.success()); let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(value["schemaVersion"], 1); - assert_eq!(value["data"]["domains"][0]["id"], "browser"); - assert_eq!(value["data"]["domains"][1]["id"], "office"); - assert_eq!(value["data"]["domains"][2]["id"], "box"); + let domains = value["data"]["domains"].as_array().unwrap(); + for id in ["browser", "office", "ocr", "box"] { + assert!( + domains.iter().any(|domain| domain["id"] == id), + "missing built-in domain {id}: {domains:?}" + ); + } assert!(value["data"].get("customJsonRpc").is_none()); assert!(value.get("jsonrpc").is_none()); } @@ -58,6 +62,10 @@ fn unified_capability_snapshot_projects_builtin_skills() { .iter() .find(|capability| capability["id"] == "use/office") .unwrap(); + let office_compat = capabilities + .iter() + .find(|capability| capability["id"] == "use/office-compat") + .unwrap(); assert_eq!(browser["origin"], "built-in"); #[cfg(feature = "browser")] { @@ -100,6 +108,15 @@ fn unified_capability_snapshot_projects_builtin_skills() { assert!(office_skill_digest .bytes() .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())); + #[cfg(feature = "mcp")] + { + assert_eq!(office["readiness"], "ready"); + assert_eq!(office["mcp"]["target"], "office-native"); + } + assert_eq!(office_compat["route"], "office-compat"); + assert_eq!(office_compat["readiness"], "missing"); + assert!(office_compat.get("mcp").is_none()); + assert!(office_compat.get("skills").is_none()); } #[cfg(not(feature = "office"))] { @@ -152,6 +169,9 @@ fn office_skill_commands_are_packaged_and_provider_independent() { .as_str() .unwrap() .contains("## Bundled reference: references/mcp.md")); + let office_skill = get["data"]["content"].as_str().unwrap(); + assert!(office_skill.contains("mcp__use_office__*")); + assert!(office_skill.contains("mcp__use_office_compat__*")); let path = Command::new(binary()) .args(["office", "skills", "path", "a3s-use-office", "--json"]) @@ -1811,6 +1831,27 @@ fn office_mcp_target_delegates_to_officeclis_standard_server() { assert!(output.stderr.is_empty()); } +#[cfg(all(unix, feature = "office"))] +#[test] +fn office_compat_mcp_target_delegates_to_officeclis_standard_server() { + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("officecli-fixture"); + std::fs::write(&executable, "#!/bin/sh\nprintf '%s\\n' \"$*\"\nexit 5\n").unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + + let output = Command::new(binary()) + .args(["mcp", "serve", "office-compat"]) + .env("A3S_OFFICECLI_EXECUTABLE", &executable) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(5)); + assert_eq!(String::from_utf8(output.stdout).unwrap(), "mcp\n"); + assert!(output.stderr.is_empty()); +} + #[cfg(all(feature = "office", feature = "mcp"))] #[tokio::test] async fn native_office_mcp_is_standard_typed_and_independent_of_officecli() { @@ -2100,6 +2141,36 @@ async fn standard_mcp_request( serde_json::from_str(&line).unwrap() } +#[cfg(feature = "ocr")] +#[test] +fn built_in_ocr_projects_the_canonical_code_route_and_skill() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + + let snapshot = Command::new(binary()) + .args(["capability", "snapshot", "--json"]) + .env("A3S_USE_HOME", &home) + .output() + .unwrap(); + assert!(snapshot.status.success(), "{snapshot:?}"); + let snapshot: serde_json::Value = serde_json::from_slice(&snapshot.stdout).unwrap(); + let ocr = snapshot["data"]["registry"]["capabilities"] + .as_array() + .unwrap() + .iter() + .find(|capability| capability["id"] == "use/ocr") + .unwrap(); + assert_eq!(ocr["route"], "ocr"); + assert_eq!(ocr["origin"], "built-in"); + assert_eq!(ocr["enabled"], true); + assert_eq!(ocr["mcp"]["target"], "ocr-native"); + assert!(ocr["skills"][0]["path"] + .as_str() + .is_some_and(|path| Path::new(path).ends_with("skills/a3s-use-ocr/SKILL.md"))); + let digest = ocr["skills"][0]["sha256"].as_str().unwrap(); + assert_eq!(digest.len(), 64); +} + #[cfg(all(unix, feature = "extensions"))] #[test] fn explicit_extension_install_delegates_native_cli_and_preserves_status() {