From 88e4ca7e5b14835a9186f0935f55cbc06ed2635a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 23:32:23 -0400 Subject: [PATCH] M13.1: printcraft-automation (headless tool set + opt-in MCP server over stdio); M4.8: CLI tools/run/mcp Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 13 + Cargo.toml | 1 + apps/printcraft-cli/Cargo.toml | 6 + apps/printcraft-cli/src/main.rs | 86 +++- crates/automation/Cargo.toml | 18 + crates/automation/src/lib.rs | 601 ++++++++++++++++++++++++++ crates/automation/src/mcp.rs | 148 +++++++ crates/automation/src/tools.rs | 179 ++++++++ crates/automation/tests/automation.rs | 283 ++++++++++++ 9 files changed, 1334 insertions(+), 1 deletion(-) create mode 100644 crates/automation/Cargo.toml create mode 100644 crates/automation/src/lib.rs create mode 100644 crates/automation/src/mcp.rs create mode 100644 crates/automation/src/tools.rs create mode 100644 crates/automation/tests/automation.rs diff --git a/Cargo.lock b/Cargo.lock index 6a0a5734e..c46fc5702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2887,10 +2887,23 @@ dependencies = [ "printcraft-ui-egui", ] +[[package]] +name = "printcraft-automation" +version = "0.1.0" +dependencies = [ + "base64", + "png 0.18.1", + "printcraft-engine", + "printcraft-organize", + "printcraft-render", + "serde_json", +] + [[package]] name = "printcraft-cli" version = "0.1.0" dependencies = [ + "printcraft-automation", "printcraft-engine", "printcraft-render", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 789450b14..10f2ecfba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ printcraft-organize = { path = "crates/organize" } printcraft-render = { path = "crates/render" } printcraft-engine = { path = "crates/engine" } printcraft-ui-egui = { path = "crates/ui-egui" } +printcraft-automation = { path = "crates/automation" } # pdf hayro = "0.7.1" diff --git a/apps/printcraft-cli/Cargo.toml b/apps/printcraft-cli/Cargo.toml index 919db0e4b..67cbb8ee2 100644 --- a/apps/printcraft-cli/Cargo.toml +++ b/apps/printcraft-cli/Cargo.toml @@ -16,3 +16,9 @@ workspace = true printcraft-render = { workspace = true } serde_json = { workspace = true } printcraft-engine = { workspace = true } +printcraft-automation = { workspace = true } + +[features] +default = ["mcp"] +# The opt-in MCP server (`printcraft-cli mcp`). Build with --no-default-features to leave it out. +mcp = [] diff --git a/apps/printcraft-cli/src/main.rs b/apps/printcraft-cli/src/main.rs index 0577091b8..2eba1df1f 100644 --- a/apps/printcraft-cli/src/main.rs +++ b/apps/printcraft-cli/src/main.rs @@ -10,8 +10,19 @@ //! printcraft-cli extract --pages 1,3,5 --out out.pdf //! printcraft-cli split (--every N | --before 3,7) [--out-dir DIR] //! printcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] +//! printcraft-cli tools automation tools and their JSON Schemas +//! printcraft-cli run [key=value …] [--root DIR] [--out image.png] +//! printcraft-cli run --script steps.json [--root DIR] [{"tool": "doc_open", "args": {…}}, …] +//! printcraft-cli mcp [--root DIR] MCP server on stdin/stdout (opt-in) //! ``` //! +//! `run` and `mcp` drive the same tool table (`printcraft-automation`). In `run`, values parse as +//! JSON when they can (`pages=[1,3]`, `degrees=90`) and are strings otherwise. A script runs its +//! steps in one session, so `doc_open` returns id 1, the next document id 2, and so on. +//! +//! The MCP server never starts on its own: it runs only when this command is launched (normally +//! by an agent configured to use it), talks only over stdio, and exits when stdin closes. +//! //! `check` is the robustness harness: every file is opened, inspected and fully rendered in a //! *separate child process* with a wall-clock timeout, so hangs, panics and aborts in any //! dependency are observed and reported instead of taking the harness down. @@ -36,11 +47,15 @@ fn main() -> ExitCode { Some("split") => split(&args[1..]), Some("check") => check(&args[1..]), Some("check-one") => check_one(&args[1..]), + Some("tools") => tools(), + Some("run") => run(&args[1..]), + #[cfg(feature = "mcp")] + Some("mcp") => mcp(&args[1..]), Some("--version") => { println!("printcraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) } - _ => Err("usage: printcraft-cli … (see source header for options)".into()), + _ => Err("usage: printcraft-cli … (see source header for options)".into()), }; match result { Ok(()) => ExitCode::SUCCESS, @@ -337,3 +352,72 @@ fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_jso } } } + +// ---- automation -------------------------------------------------------------------------------- + +fn automation(args: &[String]) -> Result { + let a = printcraft_automation::Automation::new(); + match flag(args, "--root") { + Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")), + None => Ok(a), + } +} + +fn tools() -> Result<(), String> { + let list: Vec = printcraft_automation::tools() + .iter() + .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema })) + .collect(); + println!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()); + Ok(()) +} + +/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). +fn print_output(content: Vec, out: Option<&str>) -> Result<(), String> { + for c in content { + match c { + printcraft_automation::Content::Json(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()), + printcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + std::fs::write(path, &data).map_err(|e| format!("{path}: {e}"))?; + println!("{}", serde_json::json!({ "image": path, "width": width, "height": height })); + } + None => println!( + "{}", + serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) + ), + }, + } + } + Ok(()) +} + +fn run(args: &[String]) -> Result<(), String> { + let mut auto = automation(args)?; + if let Some(script) = flag(args, "--script") { + let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; + let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?; + for (i, step) in steps.iter().enumerate() { + let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?; + let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?; + print_output(content, step["out"].as_str())?; + } + return Ok(()); + } + let tool = *positional(args).first().ok_or("run: missing tool name (see `printcraft-cli tools`)")?; + let mut obj = serde_json::Map::new(); + for kv in positional(args).iter().skip(1) { + let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; + let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); + obj.insert(k.to_string(), value); + } + let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?; + print_output(content, flag(args, "--out")) +} + +#[cfg(feature = "mcp")] +fn mcp(args: &[String]) -> Result<(), String> { + let mut server = printcraft_automation::mcp::McpServer::new(automation(args)?); + eprintln!("printcraft-cli: MCP server on stdio (protocol {}); close stdin to stop", printcraft_automation::mcp::PROTOCOL_VERSIONS[0]); + server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| e.to_string()) +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml new file mode 100644 index 000000000..f00a8d183 --- /dev/null +++ b/crates/automation/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "printcraft-automation" +description = "Agent control for PrintCraft: a headless tool set over the engine and an MCP server" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +printcraft-engine = { workspace = true } +printcraft-render = { workspace = true } +printcraft-organize = { workspace = true } +serde_json = { workspace = true } +png = "0.18" +base64 = "0.22" + diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs new file mode 100644 index 000000000..cff9614a0 --- /dev/null +++ b/crates/automation/src/lib.rs @@ -0,0 +1,601 @@ +//! printcraft-automation — agent control for PrintCraft (architecture §13). +//! +//! - **Layer:** L7. Headless: depends on the engine, never on a UI toolkit. +//! - [`Automation`] is a tool set over an engine [`Session`]: open, inspect, render, extract and +//! find text, edit pages and metadata, undo/redo, save, combine, extract and split. Every tool +//! has a JSON Schema ([`tools`]) and takes and returns JSON, so the same table drives the MCP +//! server ([`mcp`]), `printcraft-cli run` and (later) the UI control channel. +//! - Pages are **1-based** in every tool, as people number them. Rectangles are in PDF points +//! with the origin at the top-left of the displayed page. +//! - An optional root directory confines every path a tool reads or writes. + +pub mod mcp; +mod tools; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use printcraft_engine::{DocId, Document, Edit, Session, commands}; +use printcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use serde_json::{Value, json}; + +pub use tools::{ToolDef, tools}; + +/// One piece of a tool's result. +#[derive(Clone, Debug, PartialEq)] +pub enum Content { + /// Structured data (MCP: a text block with the JSON plus `structuredContent`). + Json(Value), + /// A PNG image. + Png { data: Vec, width: u32, height: u32 }, +} + +/// Why a tool call failed. +#[derive(Clone, Debug, PartialEq)] +pub enum ToolError { + /// No tool has this name. + UnknownTool(String), + /// The arguments don't match the tool's schema. + InvalidArgs(String), + /// The tool ran and failed (the message is for the agent to read). + Failed(String), +} + +impl std::fmt::Display for ToolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"), + ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m), + } + } +} + +impl std::error::Error for ToolError {} + +type Result = std::result::Result; + +fn failed(e: impl std::fmt::Display) -> ToolError { + ToolError::Failed(e.to_string()) +} + +/// Default and maximum resolution for `page_render`. +const DEFAULT_DPI: f64 = 96.0; +const MAX_DPI: f64 = 600.0; + +/// A headless PrintCraft session driven by tool calls. +pub struct Automation { + session: Session, + root: Option, + /// Synchronous renderers, rebuilt when a document's working bytes change. + renderers: HashMap>, PageRenderer)>, +} + +impl Default for Automation { + fn default() -> Self { + Self::new() + } +} + +impl Automation { + pub fn new() -> Self { + Self { session: Session::new(), root: None, renderers: HashMap::new() } + } + + /// Confine every path the tools read or write to `root` (relative paths resolve inside it). + pub fn with_root(mut self, root: impl Into) -> std::io::Result { + self.root = Some(root.into().canonicalize()?); + Ok(self) + } + + /// Use a fixed clock for saves (deterministic output in tests). + pub fn with_clock(mut self, clock: fn() -> i64) -> Self { + self.session = std::mem::take(&mut self.session).with_clock(clock); + self + } + + pub fn session(&self) -> &Session { + &self.session + } + + /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). + pub fn call(&mut self, name: &str, args: &Value) -> Result> { + let empty = json!({}); + let args = if args.is_null() { &empty } else { args }; + if !args.is_object() { + return Err(ToolError::InvalidArgs("arguments must be a JSON object".into())); + } + let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?; + tools::check_args(def, args)?; + let a = Args(args); + let out = match name { + "doc_open" => self.doc_open(&a)?, + "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), + "doc_info" => info(self.doc(&a)?), + "doc_close" => self.doc_close(&a)?, + "doc_save" => self.doc_save(&a)?, + "doc_set_info" => { + let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; + self.apply(&a, edit)? + } + "page_render" => return self.page_render(&a).map(|c| vec![c]), + "text_extract" => self.text_extract(&a)?, + "text_find" => self.text_find(&a)?, + "page_rotate" => { + let degrees = a.int("degrees")?; + if degrees % 90 != 0 { + return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into())); + } + let pages = self.pages(&a, "pages")?; + self.apply(&a, Edit::RotatePages { pages, degrees })? + } + "page_delete" => { + let pages = self.pages(&a, "pages")?; + self.apply(&a, Edit::DeletePages { pages })? + } + "page_move" => { + let pages = self.pages(&a, "pages")?; + let to = self.position(&a, "to")?; + self.apply(&a, Edit::MovePages { pages, to })? + } + "page_insert_blank" => self.insert_blank(&a)?, + "page_insert_file" => self.insert_file(&a)?, + "page_extract" => self.page_extract(&a)?, + "doc_combine" => self.doc_combine(&a)?, + "doc_split" => self.doc_split(&a)?, + "edit_undo" => { + let id = self.doc(&a)?.id; + let label = self.session.undo(id).map_err(failed)?; + json!({ "undone": label, "document": summary(self.doc(&a)?) }) + } + "edit_redo" => { + let id = self.doc(&a)?.id; + let label = self.session.redo(id).map_err(failed)?; + json!({ "redone": label, "document": summary(self.doc(&a)?) }) + } + "command_list" => self.command_list(&a)?, + other => return Err(ToolError::UnknownTool(other.into())), + }; + Ok(vec![Content::Json(out)]) + } + + // ---- documents --------------------------------------------------------------------------- + + fn doc(&self, a: &Args) -> Result<&Document> { + let id = a.int("doc")?; + let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; + self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) + } + + fn doc_open(&mut self, a: &Args) -> Result { + let path = self.resolve(a.str("path")?, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; + let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; + Ok(summary(doc)) + } + + fn doc_close(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + if doc.dirty && !a.opt_bool("discard_changes")?.unwrap_or(false) { + return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true")); + } + self.session.close(id); + self.renderers.remove(&id); + Ok(json!({ "closed": id.0 })) + } + + fn doc_save(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let target = match a.opt_str("path")? { + Some(p) => self.resolve(p, true)?, + None => PathBuf::from(doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?), + }; + let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); + // Saving to a new file is a full rewrite unless asked otherwise, like Save As. + let full = a.opt_bool("full")?.unwrap_or(!same_file); + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; + write_atomic(&target, &bytes)?; + let path = target.to_string_lossy().into_owned(); + self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; + Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": !full, "document": summary(self.doc(a)?) })) + } + + fn apply(&mut self, a: &Args, edit: Edit) -> Result { + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + Ok(summary(self.doc(a)?)) + } + + fn insert_blank(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let at = self.position(a, "at")?; + // Default to the size of the neighbouring page, as Acrobat does; Letter for empty files. + let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first()); + let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0)); + let width = a.opt_num("width")?.unwrap_or(w); + let height = a.opt_num("height")?.unwrap_or(h); + if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) { + return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into())); + } + self.apply(a, Edit::InsertBlankPage { at, width, height }) + } + + fn insert_file(&mut self, a: &Args) -> Result { + let at = self.position(a, "at")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at }) + } + + /// Write freshly created bytes to `out` and/or open them as a new document. + fn deliver(&mut self, a: &Args, name: &str, bytes: Arc>) -> Result { + let mut result = json!({ "bytes": bytes.len() }); + let out = a.opt_str("out")?; + if let Some(out) = out { + let path = self.resolve(out, true)?; + write_atomic(&path, &bytes)?; + result["path"] = json!(path.to_string_lossy()); + } + if a.opt_bool("open")?.unwrap_or(out.is_none()) { + let id = self.session.open_new(name, bytes).map_err(failed)?; + result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?); + } + Ok(result) + } + + fn page_extract(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let (id, name) = (doc.id, format!("{} (extract)", doc.name)); + let pages = self.pages(a, "pages")?; + let bytes = self.session.extract(id, &pages).map_err(failed)?; + self.deliver(a, &name, bytes) + } + + fn doc_combine(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.len() < 2 { + return Err(ToolError::InvalidArgs("combine needs at least two files".into())); + } + let mut sources = Vec::new(); + for p in paths { + let path = self.resolve(p, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + sources.push((name, Arc::new(bytes))); + } + let bytes = self.session.combine(&sources).map_err(failed)?; + self.deliver(a, "Combined", bytes) + } + + fn doc_split(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into()); + let by = match (a.opt_int("every")?, a.opt_ints("before")?) { + (Some(n), None) if n > 0 => printcraft_organize::SplitBy::PageCount(n as usize), + (None, Some(b)) => printcraft_organize::SplitBy::Before(one_based(&b)?), + _ => return Err(ToolError::InvalidArgs("pass exactly one of every (a positive page count) or before (page numbers)".into())), + }; + let dir = self.resolve(a.str("out_dir")?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let parts = self.session.split(id, &by).map_err(failed)?; + let mut files = Vec::new(); + for (i, (first, last, bytes)) in parts.iter().enumerate() { + let path = dir.join(format!("{stem}-part{}.pdf", i + 1)); + write_atomic(&path, bytes)?; + files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); + } + Ok(json!({ "files": files })) + } + + // ---- pages and text ---------------------------------------------------------------------- + + /// 1-based page list → validated 0-based indices. + fn pages(&self, a: &Args, key: &str) -> Result> { + let n = self.doc(a)?.info.pages.len(); + let pages = one_based(&a.ints(key)?)?; + if pages.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must list at least one page"))); + } + if let Some(p) = pages.iter().find(|p| **p >= n) { + return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1))); + } + Ok(pages) + } + + /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. + fn position(&self, a: &Args, key: &str) -> Result { + let n = self.doc(a)?.info.pages.len(); + let at = a.int(key)?; + if at < 1 || at as usize > n + 1 { + return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); + } + Ok(at as usize - 1) + } + + fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + let stale = self.renderers.get(&id).is_none_or(|(b, _)| !Arc::ptr_eq(b, &doc.bytes)); + if stale { + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + self.renderers.insert(id, (doc.bytes.clone(), PageRenderer::new(doc.bytes.clone(), config))); + } + Ok(&mut self.renderers.get_mut(&id).expect("inserted above").1) + } + + fn page_render(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let page = self.page(a)?; + let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI); + if !(1.0..=MAX_DPI).contains(&dpi) { + return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); + } + let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (dpi / 72.0) as f32, tag: 0 }); + if let Some(e) = out.error { + return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); + } + let data = encode_png(out.width, out.height, &out.rgba)?; + Ok(Content::Png { data, width: out.width, height: out.height }) + } + + fn page(&self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let p = a.int("page")?; + if p < 1 || p as usize > n { + return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages"))); + } + Ok(p as usize - 1) + } + + fn page_text(&mut self, id: DocId, page: usize) -> Result> { + let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }); + if let Some(e) = out.error { + return Err(failed(format!("page {}: {e}", page + 1))); + } + Ok(out.text.unwrap_or_default()) + } + + fn text_extract(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let mut out = Vec::new(); + for p in pages { + out.push(json!({ "page": p + 1, "text": self.page_text(id, p)?.plain_text() })); + } + Ok(json!({ "pages": out })) + } + + fn text_find(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let (id, n) = (doc.id, doc.info.pages.len()); + let query = a.str("query")?; + let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize; + let mut matches = Vec::new(); + 'pages: for p in 0..n { + let text = self.page_text(id, p)?; + for r in text.find(query) { + if matches.len() == limit { + break 'pages; + } + matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); + } + } + Ok(json!({ "query": query, "count": matches.len(), "matches": matches })) + } + + fn command_list(&self, a: &Args) -> Result { + let active = match a.opt_int("doc")? { + Some(_) => Some(self.doc(a)?.id), + None => None, + }; + let list: Vec = commands::COMMANDS + .iter() + .map(|c| { + json!({ + "id": c.id, + "label": commands::current_label(c, &self.session, active), + "menu": c.menu, + "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))), + "enabled": commands::is_enabled(c, &self.session, active), + "tool": tools::tool_for_command(c.id), + }) + }) + .collect(); + Ok(json!({ "commands": list })) + } + + // ---- paths ------------------------------------------------------------------------------- + + /// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that + /// does not exist yet (its nearest existing ancestor must be inside the root). + fn resolve(&self, path: &str, for_write: bool) -> Result { + let p = Path::new(path); + let joined = match &self.root { + Some(root) if p.is_relative() => root.join(p), + _ => p.to_path_buf(), + }; + let Some(root) = &self.root else { return Ok(joined) }; + let real = if for_write { + // Canonicalize the deepest existing ancestor, then re-append the rest. + let mut existing = joined.as_path(); + let mut rest = Vec::new(); + while !existing.exists() { + rest.push(existing.file_name().ok_or_else(|| failed(format!("{path}: invalid path")))?); + existing = existing.parent().ok_or_else(|| failed(format!("{path}: invalid path")))?; + } + if rest.iter().any(|c| *c == "..") { + return Err(failed(format!("{path}: '..' is not allowed here"))); + } + let mut real = existing.canonicalize().map_err(|e| failed(format!("{path}: {e}")))?; + real.extend(rest.iter().rev()); + real + } else { + joined.canonicalize().map_err(|e| failed(format!("{path}: {e}")))? + }; + if !real.starts_with(root) { + return Err(failed(format!("{path} is outside the allowed directory {}", root.display()))); + } + Ok(real) + } +} + +// ---- JSON helpers ---------------------------------------------------------------------------- + +/// Typed access to validated arguments. +struct Args<'a>(&'a Value); + +impl Args<'_> { + fn get(&self, key: &str) -> Option<&Value> { + self.0.get(key).filter(|v| !v.is_null()) + } + fn missing(key: &str) -> ToolError { + ToolError::InvalidArgs(format!("missing argument {key}")) + } + fn wrong(key: &str, what: &str) -> ToolError { + ToolError::InvalidArgs(format!("{key} must be {what}")) + } + fn str(&self, key: &str) -> Result<&str> { + self.opt_str(key)?.ok_or_else(|| Self::missing(key)) + } + fn opt_str(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() + } + fn int(&self, key: &str) -> Result { + self.opt_int(key)?.ok_or_else(|| Self::missing(key)) + } + fn opt_int(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() + } + fn opt_num(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() + } + fn opt_bool(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() + } + fn ints(&self, key: &str) -> Result> { + self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) + } + fn opt_ints(&self, key: &str) -> Result>> { + let Some(v) = self.get(key) else { return Ok(None) }; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; + arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) + } + fn strs(&self, key: &str) -> Result> { + let v = self.get(key).ok_or_else(|| Self::missing(key))?; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; + arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() + } +} + +fn one_based(pages: &[i64]) -> Result> { + pages + .iter() + .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) + .collect() +} + +/// What every tool that changes a document returns. +fn summary(d: &Document) -> Value { + json!({ + "doc": d.id.0, + "name": d.name, + "path": d.path, + "pages": d.info.pages.len(), + "dirty": d.dirty, + "editable": d.read_only_reason.is_none(), + "read_only_reason": d.read_only_reason, + "encrypted": d.info.encrypted, + "undo": d.can_undo(), + "redo": d.can_redo(), + }) +} + +fn info(d: &Document) -> Value { + let i = &d.info; + let page1 = |p: usize| p + 1; + let security = d.security_summary().map(|s| json!({ "method": s.method, "owner": s.owner, "permissions": format!("{:?}", s.permissions) })); + json!({ + "document": summary(d), + "pdf_version": i.pdf_version, + "file_size": i.file_size, + "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + "creator": i.creator, "producer": i.producer, + "tagged": i.tagged, + "has_javascript": i.has_javascript, + "security": security, + "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ + "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, + })).collect::>(), + "outline": outline(&i.outline), + "annotations": i.annotations.iter().map(|a| json!({ + "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, "rect": a.rect, + })).collect::>(), + "fields": i.fields.iter().map(|f| json!({ + "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), + "tooltip": f.tooltip, "has_actions": f.has_actions, + })).collect::>(), + "links": i.links.iter().map(|l| json!({ + "page": page1(l.page), "rect": l.rect, + "target": match &l.target { + printcraft_render::LinkTarget::Page(p) => json!({ "page": page1(*p) }), + printcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), + printcraft_render::LinkTarget::Other(o) => json!({ "other": o }), + }, + })).collect::>(), + "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(), + "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + "fonts": i.fonts.iter().map(|f| json!({ + "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, + })).collect::>(), + "warnings": i.warnings, + "repairs": d.repair_log(), + }) +} + +fn outline(items: &[printcraft_render::OutlineItem]) -> Value { + Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) +} + +fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { + let mut rgba = premultiplied.to_vec(); + for px in rgba.chunks_exact_mut(4) { + let a = u32::from(px[3]); + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + } + } + } + let mut out = Vec::new(); + let mut enc = png::Encoder::new(&mut out, width, height); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().map_err(failed)?; + w.write_image_data(&rgba).map_err(failed)?; + w.finish().map_err(failed)?; + Ok(out) +} + +/// Write via a temporary file in the same directory, then rename over the target. +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + let name = path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; + std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let tmp = dir.join(format!(".{}.printcraft-tmp", name.to_string_lossy())); + std::fs::write(&tmp, bytes).map_err(|e| failed(format!("{}: {e}", tmp.display())))?; + std::fs::rename(&tmp, path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + failed(format!("{}: {e}", path.display())) + }) +} diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs new file mode 100644 index 000000000..7d7ca87b9 --- /dev/null +++ b/crates/automation/src/mcp.rs @@ -0,0 +1,148 @@ +//! A Model Context Protocol server over the automation tools. +//! +//! **Opt-in only.** Nothing in PrintCraft starts this server on its own: it runs when a user +//! launches `printcraft-cli mcp` (usually by adding that command to their agent's MCP +//! configuration), and stops when its input closes. It opens no network port; the transport is +//! newline-delimited JSON-RPC 2.0 over stdin/stdout. +//! +//! Implemented: `initialize`, `ping`, `tools/list`, `tools/call`, and the `notifications/*` the +//! client sends. Tool failures are reported in the result (`isError: true`) so the agent can read +//! them; protocol errors use JSON-RPC error codes. + +use std::io::{BufRead, Write}; + +use base64::Engine as _; +use serde_json::{Value, json}; + +use crate::{Automation, Content, ToolError}; + +/// Protocol revisions we speak, newest first. +pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; + +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; + +const INSTRUCTIONS: &str = "PrintCraft edits PDFs. Open a file with doc_open to get a document id, then inspect \ +(doc_info, text_extract, text_find, page_render) or edit it (page_*, doc_set_info). Edits are undoable \ +(edit_undo) and stay in memory until doc_save. Page numbers are 1-based."; + +pub struct McpServer { + automation: Automation, +} + +impl McpServer { + pub fn new(automation: Automation) -> Self { + Self { automation } + } + + pub fn automation(&self) -> &Automation { + &self.automation + } + + /// Serve until `input` reaches end of file. + pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + for line in input.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + if let Some(reply) = self.handle_line(&line) { + writeln!(output, "{reply}")?; + output.flush()?; + } + } + Ok(()) + } + + /// Handle one JSON-RPC message; returns the serialized reply, if one is due. + pub fn handle_line(&mut self, line: &str) -> Option { + let reply = match serde_json::from_str::(line) { + Ok(msg) => self.handle(&msg), + Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("parse error: {e}"))), + }; + reply.map(|r| r.to_string()) + } + + /// Handle one parsed message. Notifications (no `id`) get no reply. + pub fn handle(&mut self, msg: &Value) -> Option { + let Some(obj) = msg.as_object() else { return Some(error(Value::Null, INVALID_REQUEST, "expected a JSON-RPC request object")) }; + let id = obj.get("id").cloned(); + let Some(method) = obj.get("method").and_then(Value::as_str) else { + // A response to something we sent (we send no requests) or garbage. + return id.map(|id| error(id, INVALID_REQUEST, "missing method")); + }; + let params = obj.get("params").cloned().unwrap_or(Value::Null); + let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer + Some(match self.dispatch(method, ¶ms) { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err((code, message)) => error(id, code, &message), + }) + } + + fn dispatch(&mut self, method: &str, params: &Value) -> Result { + match method { + "initialize" => { + let asked = params.get("protocolVersion").and_then(Value::as_str); + let version = asked.filter(|v| PROTOCOL_VERSIONS.contains(v)).unwrap_or(PROTOCOL_VERSIONS[0]); + Ok(json!({ + "protocolVersion": version, + "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { "name": "printcraft", "title": "PrintCraft", "version": env!("CARGO_PKG_VERSION") }, + "instructions": INSTRUCTIONS, + })) + } + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({ "tools": crate::tools().iter().map(tool_json).collect::>() })), + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "tools/call needs a tool name".to_string()))?; + let args = params.get("arguments").cloned().unwrap_or(Value::Null); + match self.automation.call(name, &args) { + Ok(content) => Ok(call_result(content)), + Err(ToolError::UnknownTool(t)) => Err((INVALID_PARAMS, format!("unknown tool {t:?}"))), + Err(e) => Ok(json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true })), + } + } + other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))), + } + } +} + +fn error(id: Value, code: i64, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +fn tool_json(t: &crate::ToolDef) -> Value { + json!({ + "name": t.name, + "title": t.title, + "description": t.description, + "inputSchema": t.input_schema, + "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "openWorldHint": false }, + }) +} + +fn call_result(content: Vec) -> Value { + let mut structured = None; + let blocks: Vec = content + .into_iter() + .map(|c| match c { + Content::Json(v) => { + let text = serde_json::to_string_pretty(&v).unwrap_or_default(); + if v.is_object() && structured.is_none() { + structured = Some(v); + } + json!({ "type": "text", "text": text }) + } + Content::Png { data, .. } => { + json!({ "type": "image", "mimeType": "image/png", "data": base64::engine::general_purpose::STANDARD.encode(data) }) + } + }) + .collect(); + let mut result = json!({ "content": blocks, "isError": false }); + if let Some(s) = structured { + result["structuredContent"] = s; + } + result +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs new file mode 100644 index 000000000..a6f675ab7 --- /dev/null +++ b/crates/automation/src/tools.rs @@ -0,0 +1,179 @@ +//! The tool table: names, descriptions and JSON Schemas for every automation tool. +//! +//! Tool names use `[a-z_]` only (MCP clients reject dots). `command` links a tool to the +//! registry id it automates, so `command_list` can tell an agent which tool runs a menu command. + +use serde_json::{Value, json}; + +use crate::ToolError; + +#[derive(Clone, Debug)] +pub struct ToolDef { + pub name: &'static str, + pub title: &'static str, + pub description: &'static str, + /// JSON Schema (draft 2020-12 subset) for the arguments object. + pub input_schema: Value, + /// Does not change any document or file. + pub read_only: bool, + /// May remove content or overwrite files. + pub destructive: bool, + /// The registry command this tool automates, if any. + pub command: Option<&'static str>, +} + +fn doc() -> Value { + json!({ "type": "integer", "minimum": 1, "description": "Document id from doc_open or doc_list." }) +} + +fn pages(what: &str) -> Value { + json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 1, "description": format!("1-based page numbers {what}.") }) +} + +fn schema(props: Value, required: &[&str]) -> Value { + json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false }) +} + +struct T { + name: &'static str, + title: &'static str, + description: &'static str, + read_only: bool, + destructive: bool, + command: Option<&'static str>, +} + +const fn t(name: &'static str, title: &'static str, description: &'static str) -> T { + T { name, title, description, read_only: false, destructive: false, command: None } +} + +impl T { + const fn ro(mut self) -> Self { + self.read_only = true; + self + } + const fn destructive(mut self) -> Self { + self.destructive = true; + self + } + const fn cmd(mut self, id: &'static str) -> Self { + self.command = Some(id); + self + } + fn with(self, input_schema: Value) -> ToolDef { + ToolDef { + name: self.name, + title: self.title, + description: self.description, + input_schema, + read_only: self.read_only, + destructive: self.destructive, + command: self.command, + } + } +} + +/// Every tool, in a stable order. +pub fn tools() -> Vec { + let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); + let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); + vec![ + t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") + .cmd("file.open") + .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), + t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, attachments, fonts, security and repair notes.") + .ro() + .cmd("file.properties") + .with(schema(json!({ "doc": doc() }), &["doc"])), + t("doc_close", "Close a document", "Close a document. Fails if it has unsaved changes unless discard_changes is true.") + .cmd("file.close") + .with(schema(json!({ "doc": doc(), "discard_changes": { "type": "boolean" } }), &["doc"])), + t( + "doc_save", + "Save a document", + "Save to its own file (an incremental update, which keeps signatures valid) or to a new path (a full rewrite). The write is atomic.", + ) + .destructive() + .cmd("file.save") + .with(schema( + json!({ "doc": doc(), "path": { "type": "string", "description": "Save as this file. Omit to save in place." }, "full": { "type": "boolean", "description": "Force a full rewrite (or, with false, an incremental update)." } }), + &["doc"], + )), + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords. Undoable.") + .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), + t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") + .ro() + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 } }), &["doc", "page"])), + t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order.") + .ro() + .with(schema(json!({ "doc": doc(), "pages": pages("to extract (default: all)") }), &["doc"])), + t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match with its page and line rectangles in points (origin top-left).") + .ro() + .cmd("edit.find") + .with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500)." } }), &["doc", "query"])), + t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise). Undoable.") + .cmd("page.rotate") + .with(schema(json!({ "doc": doc(), "pages": pages("to rotate"), "degrees": { "type": "integer" } }), &["doc", "pages", "degrees"])), + t("page_delete", "Delete pages", "Delete pages. Undoable until saved.") + .destructive() + .cmd("page.delete") + .with(schema(json!({ "doc": doc(), "pages": pages("to delete") }), &["doc", "pages"])), + t("page_move", "Move pages", "Move pages so the first of them lands at position `to` (1-based, counted before the move). Undoable.").with(schema( + json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }), + &["doc", "pages", "to"], + )), + t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.") + .cmd("page.insert_blank") + .with(schema( + json!({ "doc": doc(), "at": { "type": "integer", "minimum": 1 }, "width": { "type": "number", "description": "Points." }, "height": { "type": "number", "description": "Points." } }), + &["doc", "at"], + )), + t("page_insert_file", "Insert pages from a file", "Insert pages of another PDF so the first becomes page `at`. Undoable.") + .cmd("page.insert") + .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "at": { "type": "integer", "minimum": 1 }, "pages": pages("of the source file (default: all)") }), &["doc", "path", "at"])), + t("page_extract", "Extract pages", "Copy pages into a new PDF (links, bookmarks, fields and layers that belong to them come along).") + .cmd("page.extract") + .with(schema(json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone() }), &["doc", "pages"])), + t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file).") + .cmd("page.combine") + .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 2 }, "out": save_out, "open": open }), &["paths"])), + t("doc_split", "Split a document", "Split into several files, every N pages or before given pages, written to out_dir as -partK.pdf.") + .cmd("page.split") + .with(schema( + json!({ "doc": doc(), "every": { "type": "integer", "minimum": 1 }, "before": pages("that start a new part"), "out_dir": { "type": "string" } }), + &["doc", "out_dir"], + )), + t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), + t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), + t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") + .ro() + .with(schema(json!({ "doc": doc() }), &[])), + ] +} + +static TOOLS: std::sync::LazyLock> = std::sync::LazyLock::new(tools); + +pub(crate) fn find(name: &str) -> Option<&'static ToolDef> { + TOOLS.iter().find(|t| t.name == name) +} + +pub(crate) fn tool_for_command(id: &str) -> Option<&'static str> { + TOOLS.iter().find(|t| t.command == Some(id)).map(|t| t.name) +} + +/// Reject unknown and missing arguments (types are checked when each value is read). +pub(crate) fn check_args(def: &ToolDef, args: &Value) -> Result<(), ToolError> { + let obj = args.as_object().ok_or_else(|| ToolError::InvalidArgs("arguments must be a JSON object".into()))?; + let props = def.input_schema["properties"].as_object().cloned().unwrap_or_default(); + if let Some(k) = obj.keys().find(|k| !props.contains_key(*k)) { + let known: Vec<&str> = props.keys().map(String::as_str).collect(); + return Err(ToolError::InvalidArgs(format!("{}: unknown argument {k:?} (expected: {})", def.name, known.join(", ")))); + } + for r in def.input_schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) { + if obj.get(r).is_none_or(Value::is_null) { + return Err(ToolError::InvalidArgs(format!("{}: missing argument {r}", def.name))); + } + } + Ok(()) +} diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs new file mode 100644 index 000000000..8a53bd763 --- /dev/null +++ b/crates/automation/tests/automation.rs @@ -0,0 +1,283 @@ +//! End-to-end tests of the automation tools and the MCP server, on synthetic PDFs. + +use std::path::{Path, PathBuf}; + +use printcraft_automation::mcp::McpServer; +use printcraft_automation::{Automation, Content, ToolError, tools}; +use serde_json::{Value, json}; + +/// A PDF with `n` 200×300 pt pages reading "Page 1", "Page 2", … +fn fixture(n: usize) -> Vec { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes()); + objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); + for i in 0..n { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i).into_bytes()); + let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes()); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A fresh directory with `a.pdf` (3 pages) and `b.pdf` (2 pages). +fn workdir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("printcraft-automation-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.pdf"), fixture(3)).unwrap(); + std::fs::write(dir.join("b.pdf"), fixture(2)).unwrap(); + dir +} + +fn auto(dir: &Path) -> Automation { + Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000) +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +fn page_text(a: &mut Automation, doc: u64) -> Vec { + ok(a, "text_extract", json!({ "doc": doc }))["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap().trim().to_string()).collect() +} + +#[test] +fn tool_table_is_well_formed() { + let all = tools(); + let mut names = std::collections::HashSet::new(); + for t in &all { + assert!(names.insert(t.name), "duplicate tool {}", t.name); + assert!(t.name.len() <= 64 && t.name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "bad MCP tool name {}", t.name); + assert_eq!(t.input_schema["type"], "object", "{}", t.name); + let props = t.input_schema["properties"].as_object().unwrap(); + for r in t.input_schema["required"].as_array().unwrap() { + assert!(props.contains_key(r.as_str().unwrap()), "{}: required {r} is not a property", t.name); + } + assert!(!(t.read_only && t.destructive), "{} is both read-only and destructive", t.name); + if let Some(c) = t.command { + assert!(printcraft_engine::commands::command(c).is_some(), "{} names unregistered command {c}", t.name); + } + } +} + +#[test] +fn open_inspect_render_and_find() { + let dir = workdir("inspect"); + let mut a = auto(&dir); + let opened = ok(&mut a, "doc_open", json!({ "path": "a.pdf" })); + assert_eq!(opened["pages"], 3); + assert_eq!(opened["editable"], true); + let doc = opened["doc"].as_u64().unwrap(); + + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["pages"].as_array().unwrap().len(), 3); + assert_eq!(info["pages"][0]["width"], 200.0); + assert_eq!(info["pages"][1]["label"], "2"); + + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]); + + let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "page 2" })); + assert_eq!(found["count"], 1); + assert_eq!(found["matches"][0]["page"], 2); + let rect = found["matches"][0]["rects"][0].as_array().unwrap(); + assert!(rect[1].as_f64().unwrap() > 100.0 && rect[3].as_f64().unwrap() < 160.0, "rect is top-left based: {rect:?}"); + + let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Content::Png { data, width, height } = &png[0] else { panic!("expected an image") }; + assert_eq!((*width, *height), (200, 300)); + assert_eq!(&data[..8], b"\x89PNG\r\n\x1a\n"); + let decoder = png::Decoder::new(std::io::Cursor::new(data.as_slice())); + let info = decoder.read_info().unwrap().info().clone(); + assert_eq!((info.width, info.height), (200, 300)); +} + +#[test] +fn edit_undo_redo_and_save_round_trip() { + let dir = workdir("edit"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + + let s = ok(&mut a, "page_delete", json!({ "doc": doc, "pages": [2] })); + assert_eq!((s["pages"].as_u64(), s["dirty"].as_bool()), (Some(2), Some(true))); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 3"]); + + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]); + ok(&mut a, "edit_redo", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 3"]); + + ok(&mut a, "page_insert_file", json!({ "doc": doc, "path": "b.pdf", "pages": [2], "at": 1 })); + ok(&mut a, "page_insert_blank", json!({ "doc": doc, "at": 4 })); + ok(&mut a, "page_move", json!({ "doc": doc, "pages": [3], "to": 1 })); + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [1], "degrees": 90 })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Automated" })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1", ""]); + + let saved = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/edited.pdf" })); + assert_eq!(saved["incremental"], false); + assert_eq!(saved["document"]["dirty"], false); + + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "out/edited.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut b, "doc_info", json!({ "doc": re })); + assert_eq!(info["title"], "Automated"); + assert_eq!(info["pages"][0]["rotation"], 90); + assert_eq!(page_text(&mut b, re), ["Page 3", "Page 2", "Page 1", ""]); + + // Saving in place appends an incremental update. + ok(&mut b, "doc_set_info", json!({ "doc": re, "key": "Author", "value": "Agent" })); + let before = std::fs::metadata(dir.join("out/edited.pdf")).unwrap().len(); + let again = ok(&mut b, "doc_save", json!({ "doc": re })); + assert_eq!(again["incremental"], true); + let after = std::fs::read(dir.join("out/edited.pdf")).unwrap(); + assert!(after.len() as u64 > before); + assert_eq!(after.windows(5).filter(|w| w == b"%%EOF").count(), 2); +} + +#[test] +fn combine_extract_and_split() { + let dir = workdir("organize"); + let mut a = auto(&dir); + let combined = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "b.pdf"], "out": "ab.pdf", "open": true })); + let doc = combined["document"]["doc"].as_u64().unwrap(); + assert_eq!(combined["document"]["pages"], 5); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"]); + + let ex = ok(&mut a, "page_extract", json!({ "doc": doc, "pages": [2, 4] })); + let ex_doc = ex["document"]["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, ex_doc), ["Page 2", "Page 1"]); + assert!(ex.get("path").is_none()); + + let split = ok(&mut a, "doc_split", json!({ "doc": doc, "every": 2, "out_dir": "parts" })); + let files = split["files"].as_array().unwrap(); + assert_eq!(files.len(), 3); + assert_eq!((files[2]["first_page"].as_u64(), files[2]["last_page"].as_u64()), (Some(5), Some(5))); + for f in files { + assert!(Path::new(f["path"].as_str().unwrap()).starts_with(dir.canonicalize().unwrap())); + } +} + +#[test] +fn errors_are_specific_and_safe() { + let dir = workdir("errors"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + + let err = |a: &mut Automation, tool: &str, args: Value| a.call(tool, &args).unwrap_err(); + assert!(matches!(err(&mut a, "nope", json!({})), ToolError::UnknownTool(_))); + assert!(matches!(err(&mut a, "page_delete", json!({ "doc": doc, "pages": [9] })), ToolError::InvalidArgs(m) if m.contains("3 pages"))); + assert!(matches!(err(&mut a, "page_delete", json!({ "doc": doc, "pages": [0] })), ToolError::InvalidArgs(_))); + assert!(matches!(err(&mut a, "page_delete", json!({ "doc": doc, "page": [1] })), ToolError::InvalidArgs(m) if m.contains("unknown argument"))); + assert!(matches!(err(&mut a, "page_rotate", json!({ "doc": doc, "pages": [1], "degrees": 45 })), ToolError::InvalidArgs(_))); + assert!(matches!(err(&mut a, "doc_info", json!({ "doc": 99 })), ToolError::Failed(_))); + assert!(matches!(err(&mut a, "edit_undo", json!({ "doc": doc })), ToolError::Failed(_))); + + // Unsaved changes are never dropped silently. + ok(&mut a, "page_delete", json!({ "doc": doc, "pages": [1] })); + assert!(matches!(err(&mut a, "doc_close", json!({ "doc": doc })), ToolError::Failed(m) if m.contains("unsaved"))); + ok(&mut a, "doc_close", json!({ "doc": doc, "discard_changes": true })); + assert_eq!(ok(&mut a, "doc_list", json!({}))["documents"], json!([])); + + // The root confines reads and writes. + let outside = std::env::temp_dir().join("printcraft-automation-outside.pdf"); + std::fs::write(&outside, fixture(1)).unwrap(); + assert!(matches!(err(&mut a, "doc_open", json!({ "path": outside.to_str().unwrap() })), ToolError::Failed(m) if m.contains("outside"))); + assert!( + matches!(err(&mut a, "doc_open", json!({ "path": "../printcraft-automation-outside.pdf" })), ToolError::Failed(m) if m.contains("outside")) + ); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert!(a.call("doc_save", &json!({ "doc": doc, "path": "new/../../escape.pdf" })).is_err()); + assert!(a.call("doc_save", &json!({ "doc": doc, "path": outside.to_str().unwrap() })).is_err()); + let _ = std::fs::remove_file(outside); +} + +#[test] +fn command_list_reports_enablement_and_tools() { + let dir = workdir("commands"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "command_list", json!({ "doc": doc })); + let find = |id: &str| list["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone(); + assert_eq!(find("page.rotate")["tool"], "page_rotate"); + assert_eq!(find("page.rotate")["enabled"], true); + assert_eq!(find("edit.undo")["enabled"], false); + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [1], "degrees": 90 })); + let list = ok(&mut a, "command_list", json!({ "doc": doc })); + let undo = list["commands"].as_array().unwrap().iter().find(|c| c["id"] == "edit.undo").unwrap().clone(); + assert_eq!(undo["enabled"], true); + assert_eq!(undo["label"], "Undo Rotate page"); +} + +// ---- MCP --------------------------------------------------------------------------------------- + +fn rpc(server: &mut McpServer, id: u64, method: &str, params: Value) -> Value { + let line = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); + serde_json::from_str(&server.handle_line(&line).expect("a reply")).unwrap() +} + +#[test] +fn mcp_session_over_stdio() { + let dir = workdir("mcp"); + let path = dir.join("a.pdf"); + let input = [ + json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } } }), + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }), + json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "doc_open", "arguments": { "path": path } } }), + json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "page_render", "arguments": { "doc": 1, "page": 2, "dpi": 36 } } }), + ] + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); + let mut out = Vec::new(); + McpServer::new(Automation::new()).serve(input.as_bytes(), &mut out).unwrap(); + let replies: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 4, "the notification gets no reply"); + + assert_eq!(replies[0]["result"]["protocolVersion"], "2025-03-26"); + assert_eq!(replies[0]["result"]["serverInfo"]["name"], "printcraft"); + assert_eq!(replies[1]["result"]["tools"].as_array().unwrap().len(), tools().len()); + assert_eq!(replies[2]["result"]["structuredContent"]["pages"], 3); + assert_eq!(replies[2]["result"]["isError"], false); + let img = &replies[3]["result"]["content"][0]; + assert_eq!((img["type"].as_str(), img["mimeType"].as_str()), (Some("image"), Some("image/png"))); + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(img["data"].as_str().unwrap()).unwrap(); + assert_eq!(&png[1..4], b"PNG"); +} + +#[test] +fn mcp_errors() { + let mut s = McpServer::new(Automation::new()); + assert_eq!(rpc(&mut s, 1, "initialize", json!({ "protocolVersion": "1999-01-01" }))["result"]["protocolVersion"], "2025-06-18"); + assert_eq!(rpc(&mut s, 2, "ping", json!({}))["result"], json!({})); + assert_eq!(rpc(&mut s, 3, "resources/list", json!({}))["error"]["code"], -32601); + assert_eq!(rpc(&mut s, 4, "tools/call", json!({ "name": "nope" }))["error"]["code"], -32602); + let failed = rpc(&mut s, 5, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "/definitely/not/here.pdf" } })); + assert_eq!(failed["result"]["isError"], true); + assert!(failed["result"]["content"][0]["text"].as_str().unwrap().contains("here.pdf")); + let bad: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap(); + assert_eq!(bad["error"]["code"], -32700); +}