- 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