diff --git a/ROADMAP.md b/ROADMAP.md index 720f829e1..72e582c2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run - **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%). +- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%). + - **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. - **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged. - **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%). @@ -301,3 +303,5 @@ Newest first. One line per session: the date, what moved, and the new overall pe - Robustness sweep (963 of 983 files open, 0 crashes), text layer, find and select, tiles, web build, polish. - Overall ≈ 3–4%. - **2026-09-30 (session 1):** planning complete; viewer vertical slice. + +- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters. diff --git a/apps/pdfcraft-cli/tests/mcp_compact.rs b/apps/pdfcraft-cli/tests/mcp_compact.rs index 8c12152a3..486920680 100644 --- a/apps/pdfcraft-cli/tests/mcp_compact.rs +++ b/apps/pdfcraft-cli/tests/mcp_compact.rs @@ -41,7 +41,7 @@ fn listed_tools(flags: &[&str]) -> Vec { fn compact_flag_shortens_the_tool_list() { let full = listed_tools(&[]); let compact = listed_tools(&["--compact"]); - assert_eq!(compact.len(), 12, "{compact:?}"); + assert_eq!(compact.len(), 17, "{compact:?}"); assert!(compact.iter().any(|n| n == "tool_search") && compact.iter().any(|n| n == "tool_call")); assert!(compact.iter().all(|n| full.contains(n) || n == "tool_search" || n == "tool_call")); assert!(full.len() > 100 && !full.iter().any(|n| n == "tool_search"), "{} tools", full.len()); diff --git a/crates/automation/README.md b/crates/automation/README.md index 29e9eb9e7..3e220b677 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -32,7 +32,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS **Opt-in only.** Nothing starts it automatically, and it opens no port. It runs while `pdfcraft-cli mcp [--root DIR]` runs, normally launched by an agent from its MCP configuration. It stops when stdin closes. The CLI's `mcp` Cargo feature (on by default) compiles it out entirely when disabled. -`pdfcraft-cli mcp --compact` (or `McpServer::with_compact(true)`) keeps `tools/list` short for agents with small context budgets. It lists only the core tools (`mcp::COMPACT_CORE_TOOLS`: open, info, save, close, render, text extract and find, combine, split, undo) plus two meta tools: `tool_search` (optional `query` and `category`, the name prefix; or `name` for one tool's full `input_schema`) and `tool_call` (`name` and `arguments`, run through the same `Automation::call`). `tools/call` by name keeps working for every tool, and the server instructions mention the meta tools. Without the flag nothing changes. +`pdfcraft-cli mcp --compact` (or `McpServer::with_compact(true)`) keeps `tools/list` short for agents with small context budgets. It lists only the core tools (`mcp::COMPACT_CORE_TOOLS`: open, info, save, close, render, text extract and find, combine, split, undo) plus the common command/inspection/preview tools and two meta tools: `tool_search` (optional `query` and `category`, the name prefix; or `name` for one tool's full `input_schema`) and `tool_call` (`name` and `arguments`, run through the same `Automation::call`). `tools/call` by name keeps working for every tool, and the server instructions mention the meta tools. Without the flag nothing changes. Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`, `tools/list` (with `readOnlyHint`/`destructiveHint` annotations), `tools/call` (JSON results also returned as `structuredContent`; images as `image/png`), and `resources/list`, `resources/templates/list` and `resources/read`. The resources expose the open documents read-only: `pdfcraft://doc/{doc}/info` (JSON), `…/text`, `…/page/{page}/text` and `…/page/{page}/image{?dpi}` (PNG, 1–600 dpi). Tool failures come back as `isError: true` results, so the agent can read and recover from them. @@ -42,3 +42,29 @@ Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`, 2. Add a `ToolDef` in `src/tools.rs`: a precise description, the schema, `ro()`/`destructive()`, and `cmd()` if a registry command exists. 3. Handle it in `Automation::call`, validating pages and positions with the existing helpers. 4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links. + + +## Common command tools + +Following FilmCraft's MCP conventions, `command_list {doc?, filter?, enabled_only?}` keeps its +`{commands: [...]}` result and adds each mapped tool's parameter schema. `command_run {id, params?}` +runs that command through its existing headless tool: include `doc` and other tool arguments in +`params`. Commands without a headless mapping return a tool error. Unknown parameter keys are +ignored with a `warnings` array; required values and paths still go through the original validation. + +`command_batch {steps: [{id, params?}], stop_on_error?}` returns `completed`, `failed`, and per-step +`{ok, result?, error?}` entries. The default is to stop at the first error; earlier edits remain. +MCP marks a batch with any failed steps as `isError`, including through compact `tool_call`. +`doc_inspect {doc?}` returns one document's info, or `{documents: [...]}` for all open documents. +`render_preview {doc?, page?, max_side?}` returns a PNG (page defaults to 1, maximum side defaults +to 1024, allowed 1–4096). Omit `doc` only when exactly one document is open. + +All task tools remain available; compact mode includes the five common tools. Every MCP tool has +all four annotation hints. Unknown top-level argument keys return JSON-RPC `-32602` with the +accepted keys; failures in the invoked tool remain `isError` results. An escaped tool panic is +reported as an internal tool error, and the server continues serving. + +Exports complete synchronously. `progressToken` and cancellation notifications are harmlessly +ignored; no background-job or cancellation behavior is added. + +Session resources `pdfcraft://document` and `pdfcraft://commands`, and the refusal of unknown argument keys, are described in [docs/mcp.md](../../docs/mcp.md). diff --git a/crates/automation/src/conventions.rs b/crates/automation/src/conventions.rs new file mode 100644 index 000000000..62153082c --- /dev/null +++ b/crates/automation/src/conventions.rs @@ -0,0 +1,124 @@ +//! Shared command/inspection/preview entry points, using the existing confined tool path. +use serde_json::{Value, json}; + +use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools}; +use pdfcraft_render::{RenderRequest, RequestKind}; + +/// The most steps one `command_batch` runs. +pub(crate) const MAX_BATCH_STEPS: usize = 1000; + +impl Automation { + pub(crate) fn command_run(&mut self, args: &Args) -> Result> { + let id = args.str("id")?; + let tool = tools::tool_for_command(id).ok_or_else(|| failed(format!("command {id:?} has no headless tool; see command_list")))?; + let def = tools::find(tool).ok_or_else(|| failed("command tool is unavailable"))?; + let mut params = match args.get("params") { + None | Some(Value::Null) => serde_json::Map::new(), + Some(Value::Object(params)) => params.clone(), + Some(_) => return Err(ToolError::InvalidArgs("params must be an object".into())), + }; + let props = def.input_schema.get("properties").and_then(Value::as_object).ok_or_else(|| failed("tool has no parameter schema"))?; + let accepted = props.keys().map(String::as_str).collect::>().join(", "); + let mut warnings = Vec::new(); + params.retain(|key, _| { + if props.contains_key(key) { + return true; + } + warnings.push(format!("unknown param {key:?} for {id} (accepted: {accepted})")); + false + }); + // This preserves the task tool's validation, atomic writes and --root confinement. + let mut content = self.call(tool, &Value::Object(params))?; + if !warnings.is_empty() { + if let Some(object) = content.iter_mut().find_map(|c| match c { + Content::Json(v) => v.as_object_mut(), + _ => None, + }) { + let mut all = match object.remove("warnings") { + Some(Value::Array(old)) => old, + Some(old) => vec![old], + None => Vec::new(), + }; + all.extend(warnings.into_iter().map(Value::String)); + object.insert("warnings".into(), Value::Array(all)); + } else { + content.push(Content::Json(json!({"warnings":warnings}))); + } + } + Ok(content) + } + + pub(crate) fn command_batch(&mut self, args: &Args) -> Result { + let steps = args.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("steps must be an array".into()))?; + if steps.len() > MAX_BATCH_STEPS { + return Err(ToolError::InvalidArgs(format!("a batch has at most {MAX_BATCH_STEPS} steps ({} given); split it", steps.len()))); + } + // Check every envelope before applying any edit. + for step in steps { + let a = Args(step); + a.str("id")?; + if a.get("params").is_some_and(|p| !p.is_object() && !p.is_null()) { + return Err(ToolError::InvalidArgs("step params must be an object".into())); + } + } + let stop = args.opt_bool("stop_on_error")?.unwrap_or(true); + let (mut completed, mut failed, mut results) = (0usize, 0usize, Vec::new()); + for step in steps { + match self.command_run(&Args(step)) { + Ok(content) => { + completed += 1; + let result = content.into_iter().find_map(|c| match c { + Content::Json(v) => Some(v), + _ => None, + }); + results.push(json!({"ok":true,"result":result})); + } + Err(e) => { + failed += 1; + results.push(json!({"ok":false,"error":e.to_string()})); + if stop { + break; + } + } + } + } + Ok(json!({"completed":completed,"failed":failed,"results":results})) + } + + pub(crate) fn doc_inspect(&self, args: &Args) -> Result { + if args.opt_int("doc")?.is_some() { + return Ok(info(self.doc(args)?)); + } + Ok(json!({"documents":self.session.docs().iter().map(info).collect::>()})) + } + + pub(crate) fn render_preview(&mut self, args: &Args) -> Result { + let id = if args.opt_int("doc")?.is_some() { + self.doc(args)?.id + } else { + match self.session.docs() { + [doc] => doc.id, + _ => return Err(failed("give doc from doc_list (a single open document is selected automatically)")), + } + }; + let page = args.opt_int("page")?.unwrap_or(1); + let max = args.opt_int("max_side")?.unwrap_or(1024); + if !(1..=4096).contains(&max) { + return Err(ToolError::InvalidArgs("max_side must be between 1 and 4096".into())); + } + let page = usize::try_from(page).ok().and_then(|p| p.checked_sub(1)).ok_or_else(|| failed("page must be at least 1"))?; + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + let size = doc.info.pages.get(page).ok_or_else(|| failed("page is out of range"))?; + let side = size.width.max(size.height); + if !side.is_finite() || side <= 0.0 { + return Err(failed("page has invalid dimensions")); + } + let scale = (max as f32 / side).min(1.0); + let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0 }); + if let Some(error) = out.error { + return Err(failed(error)); + } + let data = encode_png(out.width, out.height, &out.rgba)?; + Ok(Content::Png { data, width: out.width, height: out.height }) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index ddb78956f..93fecaa30 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -14,6 +14,7 @@ mod a11y; mod comments; mod content; +mod conventions; mod forms; mod links; #[cfg(feature = "mcp")] @@ -215,6 +216,10 @@ impl Automation { json!({ "redone": label, "document": summary(self.doc(&a)?) }) } "command_list" => self.command_list(&a)?, + "command_run" => return self.command_run(&a), + "command_batch" => self.command_batch(&a)?, + "doc_inspect" => self.doc_inspect(&a)?, + "render_preview" => return self.render_preview(&a).map(|c| vec![c]), "page_number" => { use pdfcraft_organize::LabelStyle as L; let n = self.doc(&a)?.info.pages.len(); @@ -1439,6 +1444,8 @@ impl Automation { Some(_) => Some(self.doc(a)?.id), None => None, }; + let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase(); + let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false); let list: Vec = commands::COMMANDS .iter() .map(|c| { @@ -1449,9 +1456,18 @@ impl Automation { "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), + "params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema), }) }) .collect(); + let list: Vec = list + .into_iter() + .filter(|c| { + (!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true)) + && (filter.is_empty() + || ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter)))) + }) + .collect(); Ok(json!({ "commands": list })) } diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index 145190937..d114ea6d9 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -29,15 +29,32 @@ const INVALID_PARAMS: i64 = -32602; const INSTRUCTIONS: &str = "PdfCraft 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. Open documents are also \ -resources: pdfcraft://doc/{doc}/info, /text, /page/{page}/text and /page/{page}/image."; +resources: pdfcraft://doc/{doc}/info, /text, /page/{page}/text and /page/{page}/image. \ +command_list discovers command ids and tool schemas; command_run executes one, command_batch runs several. \ +doc_inspect returns open document state; render_preview returns a bounded PNG."; /// Appended to the instructions in compact mode. const COMPACT_INSTRUCTIONS: &str = " Only a core set of tools is listed. Every other tool is still available: find it with \ tool_search (optional query and category, or name for its full input schema) and run it with tool_call."; /// The tools `tools/list` returns in compact mode, besides the two meta tools. -pub const COMPACT_CORE_TOOLS: &[&str] = - &["doc_open", "doc_info", "doc_save", "doc_close", "page_render", "text_extract", "text_find", "doc_combine", "doc_split", "edit_undo"]; +pub const COMPACT_CORE_TOOLS: &[&str] = &[ + "doc_open", + "doc_info", + "doc_save", + "doc_close", + "page_render", + "text_extract", + "text_find", + "doc_combine", + "doc_split", + "edit_undo", + "command_list", + "command_run", + "command_batch", + "doc_inspect", + "render_preview", +]; /// Meta tools that exist only in compact mode. const TOOL_SEARCH: &str = "tool_search"; @@ -99,7 +116,8 @@ impl McpServer { }; 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) { + let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, ¶ms)) } else { self.dispatch(method, ¶ms) }; + Some(match result { Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), Err((code, message)) => error(id, code, &message), }) @@ -123,24 +141,35 @@ impl McpServer { 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); if self.compact && name == TOOL_SEARCH { + check_keys(name, &args, &["query", "category", "name"]).map_err(|e| (INVALID_PARAMS, e))?; return Ok(match search_tools(&args) { Ok(found) => call_result(vec![Content::Json(found)]), Err(message) => tool_error(&message), }); } if self.compact && name == TOOL_CALL { + check_keys(name, &args, &["name", "arguments"]).map_err(|e| (INVALID_PARAMS, e))?; let (inner, inner_args) = match unwrap_tool_call(&args) { Ok(pair) => pair, Err(message) => return Ok(tool_error(&message)), }; return Ok(match self.automation.call(&inner, &inner_args) { - Ok(content) => call_result(content), + Ok(content) => tool_content(&inner, content), Err(ToolError::UnknownTool(t)) => tool_error(&format!("unknown tool {t:?}; use tool_search to find the right name")), Err(e) => tool_error(&e.to_string()), }); } + if let Some(def) = crate::tools::find(name) { + let keys: Vec<&str> = def + .input_schema + .get("properties") + .and_then(Value::as_object) + .map(|p| p.keys().map(String::as_str).collect()) + .unwrap_or_default(); + check_keys(name, &args, &keys).map_err(|e| (INVALID_PARAMS, e))?; + } match self.automation.call(name, &args) { - Ok(content) => Ok(call_result(content)), + Ok(content) => Ok(tool_content(name, content)), Err(ToolError::UnknownTool(t)) => Err((INVALID_PARAMS, format!("unknown tool {t:?}"))), Err(e) => Ok(tool_error(&e.to_string())), } @@ -188,7 +217,7 @@ fn meta_tools() -> [Value; 2] { "required": [], "additionalProperties": false, }, - "annotations": { "title": "Find PdfCraft tools", "readOnlyHint": true, "destructiveHint": false, "openWorldHint": false }, + "annotations": { "title": "Find PdfCraft tools", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, }), json!({ "name": TOOL_CALL, @@ -204,7 +233,7 @@ fn meta_tools() -> [Value; 2] { "required": ["name"], "additionalProperties": false, }, - "annotations": { "title": "Run any PdfCraft tool", "readOnlyHint": false, "destructiveHint": true, "openWorldHint": false }, + "annotations": { "title": "Run any PdfCraft tool", "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, }), ] } @@ -294,6 +323,24 @@ fn unwrap_tool_call(args: &Value) -> Result<(String, Value), String> { Ok((name.to_owned(), inner)) } +/// Last-resort boundary shared by every tools/call route, including compact dispatch. +fn guarded_tool(f: impl FnOnce() -> Result) -> Result { + pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}")))) +} + +fn tool_content(name: &str, content: Vec) -> Value { + let failed = name == "command_batch" + && content.iter().any(|c| match c { + Content::Json(v) => v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0), + _ => false, + }); + let mut result = call_result(content); + if let Some(object) = result.as_object_mut() { + object.insert("isError".into(), json!(failed)); + } + result +} + /// A failed tool call, as the agent should read it. fn tool_error(message: &str) -> Value { json!({ "content": [{ "type": "text", "text": message }], "isError": true }) @@ -357,10 +404,29 @@ impl McpServer { } } } + out.extend([ + json!({"uri":"pdfcraft://document","name":"Open documents","mimeType":"application/json","description":"Session document state, as doc_inspect without arguments."}), + json!({"uri":"pdfcraft://commands","name":"Command catalog","mimeType":"application/json","description":"Commands and mapped tool schemas, as command_list without arguments."}), + ]); out } fn read_resource(&mut self, uri: &str) -> Result { + if let Some(tool) = match uri { + "pdfcraft://document" => Some("doc_inspect"), + "pdfcraft://commands" => Some("command_list"), + _ => None, + } { + let content = self.automation.call(tool, &json!({})).map_err(|e| (INVALID_PARAMS, e.to_string()))?; + let value = content + .into_iter() + .find_map(|c| match c { + Content::Json(v) => Some(v), + _ => None, + }) + .ok_or((INVALID_PARAMS, "resource returned no JSON".into()))?; + return Ok(json!({"uri":uri,"mimeType":"application/json","text":value.to_string()})); + } let r = parse_uri(uri).ok_or((INVALID_PARAMS, format!("unknown resource {uri:?}")))?; let call = |a: &mut Automation, tool: &str, args: Value| a.call(tool, &args).map_err(|e| (INVALID_PARAMS, e.to_string())); let json_of = |c: Vec| c.into_iter().find_map(|c| if let Content::Json(v) = c { Some(v) } else { None }).unwrap_or(Value::Null); @@ -413,7 +479,7 @@ fn tool_json(t: &crate::ToolDef) -> Value { "title": t.title, "description": t.description, "inputSchema": t.input_schema, - "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "openWorldHint": false }, + "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save"), "openWorldHint": false }, }) } @@ -445,6 +511,15 @@ fn call_result(content: Vec) -> Value { mod tests { use super::{Resource, first_sentence, parse_uri}; + #[test] + fn conventions_panic_is_a_tool_error() { + let r = super::guarded_tool(|| panic!("synthetic tool failure")).unwrap(); + assert_eq!(r["isError"], true); + assert!(r["content"][0]["text"].as_str().unwrap().contains("synthetic tool failure")); + let next = super::guarded_tool(|| Ok(serde_json::json!({"ok":true}))).unwrap(); + assert_eq!(next["ok"], true); + } + #[test] fn first_sentence_skips_abbreviations() { assert_eq!(first_sentence("Rotate pages. Undoable."), "Rotate pages."); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 14d7dcf4e..8eb2fa03f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1064,7 +1064,15 @@ pub fn tools() -> Vec { 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 PdfCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") .ro() - .with(schema(json!({ "doc": doc() }), &[])), + .with(schema(json!({ "doc": doc(), "filter":{"type":"string"}, "enabled_only":{"type":"boolean"} }), &[])), + t("command_run", "Run a command", "Run a registry command through its headless tool. Pass that tool's arguments in params (see command_list). Unknown params are ignored with warnings; path confinement still applies.") + .destructive().with(schema(json!({"id":{"type":"string"},"params":{"type":"object"}}), &["id"])), + t("command_batch", "Run several commands", "Run steps in order (at most 1000). Returns completed/failed counts and per-step ok/result/error; stop_on_error defaults to true.") + .destructive().with(schema(json!({"steps":{"type":"array","items":schema(json!({"id":{"type":"string"},"params":{"type":"object"}}), &["id"])},"stop_on_error":{"type":"boolean"}}), &["steps"])), + t("doc_inspect", "Inspect open documents", "With doc, return doc_info; otherwise return information for every open document (empty documents array when none are open).") + .ro().with(schema(json!({"doc":doc()}), &[])), + t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open.") + .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[])), ] } diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index a900d303a..582e7a470 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -570,11 +570,11 @@ fn mcp_resources_expose_open_documents() { let dir = workdir("mcp-resources"); let mut s = McpServer::new(auto(&dir)); assert!(rpc(&mut s, 1, "initialize", json!({}))["result"]["capabilities"]["resources"].is_object()); - assert_eq!(rpc(&mut s, 2, "resources/list", json!({}))["result"]["resources"], json!([])); + assert_eq!(rpc(&mut s, 2, "resources/list", json!({}))["result"]["resources"].as_array().unwrap().len(), 2); assert_eq!(rpc(&mut s, 3, "resources/templates/list", json!({}))["result"]["resourceTemplates"].as_array().unwrap().len(), 4); rpc(&mut s, 4, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "a.pdf" } })); let list = rpc(&mut s, 5, "resources/list", json!({}))["result"]["resources"].as_array().cloned().unwrap(); - assert_eq!(list.len(), 2 + 3, "info, text and three page images"); + assert_eq!(list.len(), 2 + 3 + 2, "info, text, three page images and two session resources"); assert_eq!(list[0]["uri"], "pdfcraft://doc/1/info"); let read = |s: &mut McpServer, uri: &str| rpc(s, 6, "resources/read", json!({ "uri": uri }))["result"]["contents"][0].clone(); let text = read(&mut s, "pdfcraft://doc/1/text"); @@ -636,7 +636,7 @@ fn mcp_compact_lists_the_core_tools_and_two_meta_tools() { let core_open = list["result"]["tools"][0].clone(); assert_eq!(Some(&core_open), full["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "doc_open")); assert!(list.to_string().len() * 5 < full.to_string().len(), "compact {} vs full {}", list.to_string().len(), full.to_string().len()); - for meta in &list["result"]["tools"].as_array().unwrap()[10..] { + for meta in &list["result"]["tools"].as_array().unwrap()[pdfcraft_automation::mcp::COMPACT_CORE_TOOLS.len()..] { assert_eq!(meta["inputSchema"]["type"], "object"); } let instructions = rpc(&mut s, 3, "initialize", json!({}))["result"]["instructions"].as_str().unwrap().to_string(); @@ -795,28 +795,28 @@ fn mcp_compact_meta_tools_reject_unknown_arguments() { let dir = workdir("mcp-compact-keys"); let mut s = compact_server(&dir); let list = rpc(&mut s, 1, "tools/list", json!({})); - let metas = &list["result"]["tools"].as_array().unwrap()[10..]; + let metas = &list["result"]["tools"].as_array().unwrap()[pdfcraft_automation::mcp::COMPACT_CORE_TOOLS.len()..]; assert_eq!(metas.len(), 2); for meta in metas { assert_eq!(meta["inputSchema"]["additionalProperties"], false, "{}", meta["name"]); } - let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string(); + let text = |r: &Value| r["error"]["message"].as_str().or_else(|| r["result"]["content"][0]["text"].as_str()).unwrap_or_default().to_string(); // A misspelled filter is an error, not an unfiltered listing. let typo = rpc(&mut s, 2, "tools/call", json!({ "name": "tool_search", "arguments": { "qurey": "rotate" } })); - assert_eq!(typo["result"]["isError"], true, "{typo}"); + assert_eq!(typo["error"]["code"], -32602, "{typo}"); assert!(text(&typo).contains("\"qurey\"") && text(&typo).contains("query"), "{}", text(&typo)); let extra = rpc(&mut s, 3, "tools/call", json!({ "name": "tool_search", "arguments": { "query": "rotate", "extra": 1 } })); - assert_eq!(extra["result"]["isError"], true, "{extra}"); + assert_eq!(extra["error"]["code"], -32602, "{extra}"); assert!(text(&extra).contains("\"extra\""), "{}", text(&extra)); // tool_call rejects extra outer keys before running anything. let misspelled = rpc(&mut s, 4, "tools/call", json!({ "name": "tool_call", "arguments": { "name": "doc_open", "argumentz": { "path": "a.pdf" } } })); - assert_eq!(misspelled["result"]["isError"], true, "{misspelled}"); + assert_eq!(misspelled["error"]["code"], -32602, "{misspelled}"); assert!(text(&misspelled).contains("\"argumentz\""), "{}", text(&misspelled)); let extra = rpc(&mut s, 5, "tools/call", json!({ "name": "tool_call", "arguments": { "name": "doc_list", "unexpected": "value" } })); - assert_eq!(extra["result"]["isError"], true, "{extra}"); + assert_eq!(extra["error"]["code"], -32602, "{extra}"); assert!(text(&extra).contains("\"unexpected\""), "{}", text(&extra)); assert_eq!(rpc(&mut s, 6, "tools/call", json!({ "name": "doc_list", "arguments": {} }))["result"]["structuredContent"]["documents"], json!([])); @@ -3279,3 +3279,102 @@ mod combine_argument_tests { } } } + +#[cfg(feature = "mcp")] +#[test] +fn conventions_core_tools_preserve_root_and_batch_errors() { + let dir = workdir("conventions-core"); + let mut s = McpServer::new(auto(&dir)); + let run = |s: &mut McpServer, name: &str, args: Value| rpc(s, 1, "tools/call", json!({"name":name,"arguments":args})); + assert_eq!(run(&mut s, "doc_inspect", json!({}))["result"]["structuredContent"]["documents"], json!([])); + let opened = run(&mut s, "command_run", json!({"id":"file.open","params":{"path":"a.pdf"}})); + assert_eq!(opened["result"]["isError"], false, "{opened}"); + let doc = opened["result"]["structuredContent"]["doc"].as_u64().unwrap(); + let filtered = run(&mut s, "command_list", json!({"doc":doc,"filter":"rotate","enabled_only":true})); + let commands = filtered["result"]["structuredContent"]["commands"].as_array().unwrap(); + assert!(!commands.is_empty()); + assert!(commands.iter().all(|c| c["enabled"] == true)); + assert!(commands.iter().find(|c| c["id"] == "page.rotate").unwrap()["params"].is_object()); + for stop in [true, false] { + let r = run( + &mut s, + "command_batch", + json!({"steps":[{"id":"page.rotate","params":{"doc":doc,"degrees":90}},{"id":"no.such.command"},{"id":"page.rotate","params":{"doc":doc,"degrees":90}}],"stop_on_error":stop}), + ); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert_eq!(r["result"]["structuredContent"]["completed"], if stop { 1 } else { 2 }); + assert_eq!(r["result"]["structuredContent"]["failed"], 1); + } + let r = run(&mut s, "command_run", json!({"id":"file.save","params":{"doc":doc,"path":"../escaped.pdf"}})); + assert_eq!(r["result"]["isError"], true); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the allowed directory")); + let r = run(&mut s, "command_run", json!({"id":"page.rotate","params":{"doc":doc,"degrees":90,"typo":1}})); + assert_eq!(r["result"]["isError"], false); + assert!(r["result"]["structuredContent"]["warnings"][0].as_str().unwrap().contains("typo")); + let before = run(&mut s, "doc_info", json!({"doc":doc})); + let r = run(&mut s, "render_preview", json!({"doc":doc,"page":1,"max_side":64})); + assert_eq!(r["result"]["isError"], false, "{r}"); + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(r["result"]["content"][0]["data"].as_str().unwrap()).unwrap(); + let image = image::load_from_memory(&png).unwrap(); + assert!(image.width().max(image.height()) <= 64); + assert_eq!(before["result"], run(&mut s, "doc_info", json!({"doc":doc}))["result"]); +} + +#[cfg(feature = "mcp")] +#[test] +fn conventions_annotations_and_strict_keys_in_both_modes() { + for compact in [false, true] { + let mut s = McpServer::new(Automation::new()).with_compact(compact); + let r = rpc(&mut s, 1, "tools/list", json!({})); + let tools = r["result"]["tools"].as_array().unwrap(); + for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview"] { + assert!(tools.iter().any(|t| t["name"] == name), "missing {name}"); + } + for tool in tools { + for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] { + assert!(tool["annotations"][hint].is_boolean(), "{}: {hint}", tool["name"]); + } + let r = rpc(&mut s, 2, "tools/call", json!({"name":tool["name"],"arguments":{"bogus_arg":1}})); + assert_eq!(r["error"]["code"], -32602, "{}", tool["name"]); + let message = r["error"]["message"].as_str().unwrap(); + assert!(message.contains("bogus_arg") && message.contains("expected:")); + } + } +} + +#[cfg(feature = "mcp")] +#[test] +fn conventions_resources() { + let dir = workdir("conventions-resources"); + let mut s = McpServer::new(auto(&dir)); + let read = |s: &mut McpServer, uri: &str| { + let r = rpc(s, 1, "resources/read", json!({"uri":uri})); + serde_json::from_str::(r["result"]["contents"][0]["text"].as_str().expect("JSON resource")).unwrap() + }; + assert_eq!(read(&mut s, "pdfcraft://document"), json!({"documents":[]})); + rpc(&mut s, 2, "tools/call", json!({"name":"doc_open","arguments":{"path":"a.pdf"}})); + let doc = rpc(&mut s, 3, "tools/call", json!({"name":"doc_inspect","arguments":{}})); + assert_eq!(read(&mut s, "pdfcraft://document"), doc["result"]["structuredContent"]); + let commands = rpc(&mut s, 4, "tools/call", json!({"name":"command_list","arguments":{}})); + assert_eq!(read(&mut s, "pdfcraft://commands"), commands["result"]["structuredContent"]); + // Responses keep the shape every supported protocol revision expects: no cache hints. + for (method, params) in [("tools/list", json!({})), ("resources/list", json!({})), ("resources/read", json!({"uri":"pdfcraft://document"}))] { + assert!(rpc(&mut s, 5, method, params)["result"].get("resultType").is_none()); + } + assert_eq!(rpc(&mut s, 7, "initialize", json!({"protocolVersion":"2026-07-28"}))["result"]["protocolVersion"], "2025-06-18"); + assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":999}}"#).is_none()); + assert_eq!( + rpc(&mut s, 9, "tools/call", json!({"name":"doc_inspect","arguments":{},"_meta":{"progressToken":"quick"}}))["result"]["isError"], + false + ); +} + +#[test] +fn command_batch_refuses_more_than_a_thousand_steps() { + let dir = workdir("batch-cap"); + let mut a = auto(&dir); + let steps: Vec = (0..1001).map(|_| json!({"id":"no.such.command"})).collect(); + let err = a.call("command_batch", &json!({"steps": steps})).unwrap_err(); + assert!(err.to_string().contains("at most 1000 steps"), "{err}"); +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index a33fb5bda..319bc3ef5 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1680,3 +1680,209 @@ fn check_box_redraw_keeps_down_states_it_still_draws() { assert!(d.contains(on.as_bytes()) && d.contains(b"Off")); assert!(!d.contains(b"Gone"), "a state the box no longer draws"); } + +#[test] +fn filling_and_authoring_keep_shared_appearances_and_drop_stale_alternates() { + let alternates: [(&[u8], &[u8]); 2] = [(b"R", b"0 0 20 10 re f\n"), (b"D", b"1 1 18 8 re S\n")]; + let cases = [ + (NewField::Text { multiline: false }, true), + (NewField::Text { multiline: false }, false), + (NewField::CheckBox, false), + (NewField::Button { caption: "Button".into() }, false), + (NewField::Signature, false), + ]; + for (kind, fill) in cases { + for indirect in [false, true] { + let mut doc = one_page(); + let first = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 630.0], &kind, Some("First")).unwrap(); + let other = add_field(&mut doc, 0, [50.0, 550.0, 250.0, 580.0], &kind, Some("Other")).unwrap(); + let first_widget = field(&fields(&doc), &first).widgets[0].obj; + let other_widget = field(&fields(&doc), &other).widgets[0].obj; + let mut entries = doc.get(first_widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); + for (key, bytes) in alternates { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 10.into()])); + let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, bytes.to_vec()))); + entries.set(key.to_vec(), Object::Ref(r)); + } + entries.set(b"VendorState".to_vec(), Object::name("Retained")); + let shared = if indirect { Object::Ref(doc.add(Object::Dict(entries))) } else { Object::Dict(entries) }; + for widget in [first_widget, other_widget] { + doc.update_dict(widget, |d| d.set(b"AP".to_vec(), shared.clone())).unwrap(); + } + let original = reopen(&doc); + for full in [false, true] { + let mut doc = original.clone(); + let other_widget = field(&fields(&doc), &other).widgets[0].obj; + let source = doc.get(other_widget).as_dict().unwrap().get(b"AP").unwrap().clone(); + let before = doc.resolve(&source); + if fill { + set_value(&mut doc, &first, &FieldValue::Text("Updated value".into())).unwrap(); + } else { + set_props(&mut doc, &first, &FieldProps { tooltip: Some("Updated help".into()), ..FieldProps::default() }).unwrap(); + } + assert_eq!(doc.resolve(&source), before, "a shared AP must not change"); + let bytes = if full { + pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() + } else { + assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision"); + assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite"); + let original = doc.bytes(); + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(bytes.len() > original.len(), "incremental save appends the changed appearance"); + assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix"); + bytes + }; + hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let all = fields(&doc); + let first = field(&all, &first); + let other = field(&all, &other); + let first_ap = doc.resolve(doc.get(first.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap()); + let other_ap = doc.resolve(doc.get(other.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap()); + let first_ap = first_ap.as_dict().unwrap(); + let other_ap = other_ap.as_dict().unwrap(); + assert_ne!(first_ap.get(b"N"), other_ap.get(b"N"), "normal appearance was regenerated"); + assert_eq!(other_ap.len(), 4); + // The redrawn widget keeps unknown entries, but not down/rollover looks that + // would show its old value or caption on press or hover. + assert_eq!( + first_ap.len(), + 2, + "{kind:?} fill={fill} indirect={indirect} full={full}: {:?}", + first_ap.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::>() + ); + for (key, bytes) in alternates { + assert!(first_ap.get(key).is_none(), "stale /{}", String::from_utf8_lossy(key)); + let object = doc.resolve(other_ap.get(key).unwrap()); + let Object::Stream(stream) = &*object else { panic!("alternate appearance") }; + assert_eq!(stream.decoded().unwrap(), bytes); + } + assert_eq!(first_ap.name(b"VendorState"), Some(&b"Retained"[..])); + assert_eq!(other_ap.name(b"VendorState"), Some(&b"Retained"[..])); + if fill { + assert_eq!(first.value, ["Updated value"]); + assert!(ap(&doc, &first.widgets[0]).contains("(Updated value) Tj")); + assert!(other.value.is_empty()); + } else { + assert_eq!(first.tooltip.as_deref(), Some("Updated help")); + assert_eq!(other.tooltip, None); + } + } + } + } +} + +#[test] +fn pushbutton_redraw_resolves_caption_and_layout_after_save_and_reopen() { + fn saved(doc: &Document, full: bool) -> Document { + let bytes = if full { + pdfcraft_cos::write_full(doc, &SaveOptions::default()).unwrap() + } else { + assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision"); + assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite"); + let original = doc.bytes(); + let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); + assert!(bytes.len() > original.len(), "incremental save appends the changed appearance"); + assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix"); + bytes + }; + hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + Document::open(Arc::new(bytes)).unwrap() + } + + for indirect_caption in [false, true] { + for indirect_layout in [false, true] { + for icon_only in [false, true] { + let mut doc = one_page(); + let name = + add_field(&mut doc, 0, [50.0, 600.0, 250.0, 650.0], &NewField::Button { caption: "Original caption".into() }, Some("Picture")) + .unwrap(); + let widget = field(&fields(&doc), &name).widgets[0].obj; + let caption = Object::String(pdfcraft_cos::PdfString::literal(b"Original caption".to_vec())); + let caption = if indirect_caption { Object::Ref(doc.add(caption)) } else { caption }; + let layout = Object::Int(i64::from(icon_only)); + let layout = if indirect_layout { Object::Ref(doc.add(layout)) } else { layout }; + let mut icon_dict = Dict::new(); + icon_dict.set(b"Type".to_vec(), Object::name("XObject")); + icon_dict.set(b"Subtype".to_vec(), Object::name("Form")); + icon_dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 4.into(), 2.into()])); + let icon_bytes = b"0 0 4 2 re f\n"; + let icon = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(icon_dict, icon_bytes.to_vec()))); + let mut mk = Dict::new(); + mk.set(b"CA".to_vec(), caption); + mk.set(b"TP".to_vec(), layout); + mk.set(b"I".to_vec(), Object::Ref(icon)); + mk.set(b"VendorNote".to_vec(), Object::name("Retained")); + let mk = doc.add(Object::Dict(mk)); + doc.update_dict(widget, |d| d.set(b"MK".to_vec(), Object::Ref(mk))).unwrap(); + + // Import the operands from a real saved file before an unrelated public edit. + let original = saved(&doc, true); + for full in [false, true] { + let mut doc = original.clone(); + let widget = field(&fields(&doc), &name).widgets[0].obj; + let mk_before = doc.get(widget).as_dict().unwrap().get(b"MK").unwrap().clone(); + let original_mk = doc.resolve(&mk_before); + set_props(&mut doc, &name, &FieldProps { tooltip: Some("Picture help".into()), ..FieldProps::default() }).unwrap(); + assert_eq!(doc.get(widget).as_dict().unwrap().get(b"MK"), Some(&mk_before)); + assert_eq!(doc.resolve(&mk_before), original_mk, "redraw must not rewrite the source MK"); + let doc = saved(&doc, full); + let f = field(&fields(&doc), &name).clone(); + assert_eq!(f.tooltip.as_deref(), Some("Picture help")); + let w = &f.widgets[0]; + let wd = doc.get(w.obj); + let mk_object = doc.resolve(wd.as_dict().unwrap().get(b"MK").unwrap()); + let mk = mk_object.as_dict().unwrap(); + assert_eq!(mk.get(b"CA").unwrap().as_ref().is_some(), indirect_caption); + assert_eq!(doc.resolve(mk.get(b"CA").unwrap()).as_string().unwrap().bytes, b"Original caption"); + assert_eq!(mk.get(b"TP").unwrap().as_ref().is_some(), indirect_layout); + assert_eq!(doc.resolve(mk.get(b"TP").unwrap()).as_int(), Some(i64::from(icon_only))); + assert_eq!(mk.name(b"VendorNote"), Some(&b"Retained"[..])); + let appearance = ap(&doc, w); + assert_eq!(appearance.contains("(Original caption) Tj"), !icon_only, "{appearance}"); + assert_eq!(appearance.matches("/Icon Do").count(), 1, "{appearance}"); + assert!(appearance.contains("24.000000 0 0 24.000000 52.000 1.000 cm"), "{appearance}"); + let n = wd.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let appearance_object = doc.get(n); + let Object::Stream(stream) = &*appearance_object else { panic!("appearance") }; + let resource_icon = + stream.dict.get(b"Resources").unwrap().as_dict().unwrap().get(b"XObject").unwrap().as_dict().unwrap().get(b"Icon").unwrap(); + assert_eq!(resource_icon, mk.get(b"I").unwrap()); + let icon_object = doc.resolve(resource_icon); + let Object::Stream(icon) = &*icon_object else { panic!("icon") }; + assert_eq!(icon.decoded().unwrap(), icon_bytes); + } + } + } + } +} + +/// A check box's down appearances survive a redraw for the states it still draws (same names), +/// and a down appearance for a state it no longer has is dropped rather than left stale. +#[test] +fn check_box_redraw_keeps_down_states_it_still_draws() { + let mut doc = one_page(); + let name = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::CheckBox, Some("agree")).unwrap(); + let widget = field(&fields(&doc), &name).widgets[0].obj; + let on = field(&fields(&doc), &name).widgets[0].on_state.clone().unwrap_or_else(|| "Yes".into()); + let mut ap = doc.get(widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); + let mut down = Dict::new(); + for state in [on.as_bytes(), b"Off".as_slice(), b"Gone".as_slice()] { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 20.into()])); + let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"0 0 20 20 re f\n".to_vec()))); + down.set(state.to_vec(), Object::Ref(r)); + } + ap.set(b"D".to_vec(), Object::Dict(down)); + doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap))).unwrap(); + set_props(&mut doc, &name, &FieldProps { tooltip: Some("Tick to agree".into()), ..FieldProps::default() }).unwrap(); + let ap = doc.get(widget).as_dict().unwrap().get(b"AP").map(|a| doc.resolve(a)).unwrap(); + let d = ap.as_dict().unwrap().get(b"D").map(|d| doc.resolve(d)).expect("down states kept"); + let d = d.as_dict().unwrap(); + assert!(d.contains(on.as_bytes()) && d.contains(b"Off")); + assert!(!d.contains(b"Gone"), "a state the box no longer draws"); +} diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000..5465c5b9c --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,45 @@ +# MCP conventions + +Start explicitly with `pdfcraft-cli mcp [--root DIR] [--compact]`. The server uses JSON lines on +stdin/stdout, opens no port, and stops on EOF. `--root` confines all reads and writes, including +commands invoked through the common tools. Logs stay on stderr. + +| Tool | Arguments | +|---|---| +| `command_list` | `doc?`, `filter?`, `enabled_only?`; returns `{commands: [...]}` with mapped tool schemas | +| `command_run` | `id`, `params?` (the mapped task tool's arguments, including `doc` when required) | +| `command_batch` | `steps: [{id, params?}]`, `stop_on_error?` (default true) | +| `doc_inspect` | `doc?`; one document's info or `{documents: [...]}` for the session | +| `render_preview` | `doc?`, `page?` (1-based, default 1), `max_side?` (1–4096, default 1024) | + +All existing task tools remain available. Compact mode lists these five tools alongside its +existing core set and `tool_search` / `tool_call`. There are no legacy-name aliases. The full +[automation guide](../crates/automation/README.md) describes the tools, resources, coordinate +system, root confinement, and errors. + +```text +command_run {"id":"file.open","params":{"path":"input.pdf"}} + → {"doc":1,...} +command_list {"doc":1,"filter":"rotate"} +command_run {"id":"page.rotate","params":{"doc":1,"degrees":90}} +render_preview {"doc":1,"page":1,"max_side":512} +``` + +Every tool has a title and the four MCP annotation hints. Unknown top-level argument keys return +JSON-RPC `-32602`; command parameter keys that do not belong to the mapped task tool are ignored +with warnings. Task failures and escaped tool panics return `isError: true`, and the server keeps +serving. A failed batch includes per-step errors and is marked `isError: true`. + +Exports complete synchronously. A call's `progressToken` and cancellation notifications are +harmlessly ignored; the server does not provide background export progress or cancellation. + +## Resources and protocol versions + +`pdfcraft://document` returns the session's document information (as `doc_inspect` without +arguments), including an empty `documents` array before any PDF is opened. `pdfcraft://commands` +returns the command catalog (as `command_list`). Existing per-document info, text and page-image +resources and their templates are preserved. + +Unknown top-level argument keys are refused with JSON-RPC `-32602` (invalid params) instead of +being ignored, so a misspelled argument is reported rather than silently dropped. Clients that +sent extra keys before get an error now. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 02d60ad72..6a83d48f0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9349,8 +9349,8 @@ tier = "P0" milestone = "M13" status = "shipped" commands = [] -tools = [] -evidence = ["crates/automation/tests/automation.rs::mcp_session_over_stdio", "crates/automation/tests/automation.rs::mcp_errors"] +tools = ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview"] +evidence = ["crates/automation/tests/automation.rs::mcp_session_over_stdio", "crates/automation/tests/automation.rs::mcp_errors", "crates/automation/tests/automation.rs::conventions_core_tools_preserve_root_and_batch_errors", "crates/automation/tests/automation.rs::conventions_annotations_and_strict_keys_in_both_modes"] notes = "" [[feature]] @@ -9374,7 +9374,7 @@ milestone = "M13" status = "shipped" commands = [] tools = ["doc_info", "text_extract", "page_render"] -evidence = ["crates/automation/tests/automation.rs::mcp_resources_expose_open_documents", "crates/automation/src/mcp.rs::resource_uris_parse"] +evidence = ["crates/automation/tests/automation.rs::mcp_resources_expose_open_documents", "crates/automation/src/mcp.rs::resource_uris_parse", "crates/automation/tests/automation.rs::conventions_resources_and_modern_results"] notes = "resources/list, resources/templates/list and resources/read: pdfcraft://doc/{doc}/info (JSON), /text, /page/{page}/text, /page/{page}/image?dpi= (PNG)" [[feature]]