MCP: common command tools, session resources and client hints (#451)

* XFA: choice lists, signature, password, image and barcode fields; PNG and GIF pictures

The xfa crate lays out the field kinds that were left blank or shown as
text fields:

- Choice lists become combo boxes (editable with textEntry) or list
  boxes (open="always"; multi-select with open="multiSelect"), with the
  shown items and the saved values of <items save="1"> as /Opt pairs,
  /V, /I and /DV as saved values, several on separate lines in the
  data. Field::saved_value maps shown text to saved values for the
  layout and the script tree alike, so scripts read what is saved.
- Password fields are password text fields; signature fields are /Sig
  fields the app can sign.
- Image fields show the picture the data (base64) or the template
  holds; barcode fields show their value as text, with a warning that
  no bars are drawn.
- PNG and GIF pictures (fields and draws) are decoded to Flate image
  XObjects with a soft mask for transparency; JPEGs stay as they were.
  Sizes come from the headers, so layout never decodes; decoding is
  capped at 40 million pixels and failures are warnings, not errors.

Engine: multi-select list values from scripts and data are one per
line. README and parity notes updated (form.xfa-dynamic).

Hardened after an adversarial review pass: picture sizes refused from
the header past 20 million pixels or 65,535 a side, the GIF decoder
given limits (a frame far larger than its screen is refused before
allocation), template pictures read whole up to 32 MB, pictures
decoded once and remembered in the catalog by content hash so
re-layouts reuse them, data pictures that aren't images reported by
field; choice values matched against saved values first, several
values deduplicated and capped, /I ascending and unique, /DV through
the same mapping, <value> children read, the item cap reported; the
reopen path takes another viewer's choice values (several per line);
CMYK JPEGs inverted only with Adobe APP14.

* D5: README for cos and engine, and an honest cos status line

Thirteen crates have no README, although the session protocol tells every session to read the README of the crate it is touching. These are the two biggest: cos is where every hostile file lands first, and engine is the whole API surface a frontend gets.

Each states its layer and the rules that bind it, sketches the API against the real signatures, and ends with what is deliberately not done. The cos README spells out the clone-is-a-snapshot property and why both writers return bytes rather than taking a sink; the engine README spells out the four-step editing model that makes what you see what Save writes.

Also corrects the module doc on cos/src/lib.rs, which still said encryption and object-stream output for full saves were not done. Both shipped: security.rs handles R2-R6 with crypt filters and /EFF, and SaveOptions::object_streams defaults to true.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Preserve alternate appearances and indirect push-button values

Keep rollover, down and custom appearance entries when normal appearances are regenerated, without modifying shared dictionaries. Resolve indirect button caption and layout values. Cover full and incremental saved output, and wait for raster readiness in the existing Linux scroll test.

Co-authored-by: Codex <[email protected]>

* M13: common MCP tools through the confined automation path

Preserve task tools and compact mode, add complete hints and strict argument-key errors, and catch escaped tool panics.

Co-Authored-By: OpenAI Codex <[email protected]>

* M13: session resources and version-aware MCP result hints

Keep per-document resources and legacy response shapes; add document/command resources and private cache hints for modern clients.

Co-Authored-By: OpenAI Codex <[email protected]>

* M7: select and edit grouped Form XObject figures

Co-Authored-By: Codex <[email protected]>

* M14: let neutral Windows locales fall through to display language

Co-authored-by: Codex <[email protected]>

* pdfcraft-cli --help lists `mcp` only when the build has it

Follow-up to #540: a build without the `mcp` feature (`--no-default-features`) still listed
`pdfcraft-cli mcp` in its usage, and `pdfcraft-cli mcp` then said "unknown command".

USAGE is now put together with `concat!`, and its `mcp` lines come from a macro that is empty
without the feature. In such a build `pdfcraft-cli mcp` says the MCP server was left out instead
of calling it an unknown command.

- tests/help.rs: usage_lists_mcp_only_when_built_with_it (runs in both feature sets) and
  mcp_without_the_feature_says_it_was_left_out (without the feature)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* engine README: the execution plan is local-only, so don't link a path that doesn't exist

* Merge land/450 into land/414; XFA pictures are found again by a SHA-256 on their XObject

The image cache was a /PCXfaImages map in the catalog, keyed by DefaultHasher
(not stable across Rust releases) and read back from the file. Each image
XObject now records the SHA-256 of its picture's bytes, and a re-layout finds
the XObjects on the pages it replaces by that hash; nothing goes in the catalog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Merge land/414 into land/451; keep main's MCP protocol versions, cap command_batch at 1000 steps

The 2026-07-28 revision and its resultType/ttlMs/cacheScope fields are dropped:
main doesn't advertise them and they couldn't be checked against the spec.
command_batch refuses more than 1000 steps. docs/mcp.md says that unknown
argument keys are now refused with -32602 (a behaviour change for clients that
sent extra keys).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Yuvraj Singh <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: sanyamsingh001 <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: storybored379 <[email protected]>
Co-authored-by: OpenAI Codex <[email protected]>
Co-authored-by: Flowershangfromthebranches <[email protected]>
Co-authored-by: Streetblock <[email protected]>
Co-authored-by: lloyd <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
11 files changed
+627 -24

No files matched your search

+4
View File
@@ -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.
+1 -1
View File
@@ -41,7 +41,7 @@ fn listed_tools(flags: &[&str]) -> Vec<String> {
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());
+27 -1
View File
@@ -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).
+124
View File
@@ -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<Vec<Content>> {
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::<Vec<_>>().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<Value> {
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<Value> {
if args.opt_int("doc")?.is_some() {
return Ok(info(self.doc(args)?));
}
Ok(json!({"documents":self.session.docs().iter().map(info).collect::<Vec<_>>()}))
}
pub(crate) fn render_preview(&mut self, args: &Args) -> Result<Content> {
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 })
}
}
+16
View File
@@ -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<Value> = 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<Value> = 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 }))
}
+84 -9
View File
@@ -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, &params) {
let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, &params)) } else { self.dispatch(method, &params) };
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<Value, (i64, String)>) -> Result<Value, (i64, String)> {
pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}"))))
}
fn tool_content(name: &str, content: Vec<Content>) -> 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<Value, (i64, String)> {
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<Content>| 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<Content>) -> 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.");
+9 -1
View File
@@ -1064,7 +1064,15 @@ pub fn tools() -> Vec<ToolDef> {
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}}), &[])),
]
}
+108 -9
View File
@@ -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::<Value>(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<Value> = (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}");
}
+206
View File
@@ -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::<Vec<_>>()
);
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");
}
+45
View File
@@ -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.
+3 -3
View File
@@ -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]]