mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
49c0c6bffc
commit
88e4ca7e5b
Generated
+13
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -10,8 +10,19 @@
|
||||
//! printcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
|
||||
//! printcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
|
||||
//! printcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
|
||||
//! printcraft-cli tools automation tools and their JSON Schemas
|
||||
//! printcraft-cli run <tool> [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 <info|render|text|edit|combine|extract|split|check> … (see source header for options)".into()),
|
||||
_ => Err("usage: printcraft-cli <info|render|text|edit|combine|extract|split|check|tools|run|mcp> … (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<printcraft_automation::Automation, String> {
|
||||
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<serde_json::Value> = 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<printcraft_automation::Content>, 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::Value> = 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())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<u8>, 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<T> = std::result::Result<T, ToolError>;
|
||||
|
||||
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<PathBuf>,
|
||||
/// Synchronous renderers, rebuilt when a document's working bytes change.
|
||||
renderers: HashMap<DocId, (Arc<Vec<u8>>, 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<PathBuf>) -> std::io::Result<Self> {
|
||||
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<Vec<Content>> {
|
||||
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::<Vec<_>>() }),
|
||||
"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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Vec<u8>>) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Vec<usize>> {
|
||||
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<usize> {
|
||||
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<Content> {
|
||||
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<usize> {
|
||||
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<Arc<printcraft_render::PageText>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let active = match a.opt_int("doc")? {
|
||||
Some(_) => Some(self.doc(a)?.id),
|
||||
None => None,
|
||||
};
|
||||
let list: Vec<Value> = 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<PathBuf> {
|
||||
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<Option<&str>> {
|
||||
self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose()
|
||||
}
|
||||
fn int(&self, key: &str) -> Result<i64> {
|
||||
self.opt_int(key)?.ok_or_else(|| Self::missing(key))
|
||||
}
|
||||
fn opt_int(&self, key: &str) -> Result<Option<i64>> {
|
||||
self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose()
|
||||
}
|
||||
fn opt_num(&self, key: &str) -> Result<Option<f64>> {
|
||||
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<Option<bool>> {
|
||||
self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose()
|
||||
}
|
||||
fn ints(&self, key: &str) -> Result<Vec<i64>> {
|
||||
self.opt_ints(key)?.ok_or_else(|| Self::missing(key))
|
||||
}
|
||||
fn opt_ints(&self, key: &str) -> Result<Option<Vec<i64>>> {
|
||||
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::<Result<Vec<_>>>().map(Some)
|
||||
}
|
||||
fn strs(&self, key: &str) -> Result<Vec<&str>> {
|
||||
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<Vec<usize>> {
|
||||
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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
"fonts": i.fonts.iter().map(|f| json!({
|
||||
"name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding,
|
||||
})).collect::<Vec<_>>(),
|
||||
"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<Vec<u8>> {
|
||||
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()))
|
||||
})
|
||||
}
|
||||
@@ -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<String> {
|
||||
let reply = match serde_json::from_str::<Value>(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<Value> {
|
||||
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<Value, (i64, String)> {
|
||||
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::<Vec<_>>() })),
|
||||
"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<Content>) -> Value {
|
||||
let mut structured = None;
|
||||
let blocks: Vec<Value> = 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
|
||||
}
|
||||
@@ -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<ToolDef> {
|
||||
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 <name>-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<Vec<ToolDef>> = 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(())
|
||||
}
|
||||
@@ -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<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
|
||||
let kids: Vec<String> = (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<String> {
|
||||
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::<Vec<_>>()
|
||||
.join("\n");
|
||||
let mut out = Vec::new();
|
||||
McpServer::new(Automation::new()).serve(input.as_bytes(), &mut out).unwrap();
|
||||
let replies: Vec<Value> = 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);
|
||||
}
|
||||
Reference in New Issue
Block a user