Files
wordcraft/crates/mcp/src/tools.rs
T
02e2a51bb4 LaTeX (.tex) import and export (#7) (#11)
* LaTeX (.tex) import and export (#7)

Open and save LaTeX source as a document format, next to Markdown and
HTML. Export writes a standalone article (fontenc, ulem, xcolor,
multirow, amsmath, hyperref, loaded only when used): unnumbered sections,
itemize/enumerate, quote, center/flushright, verbatim, tabular with the
document's column widths and merged cells, equations as $..$ / \[..\],
character formatting, colours and links; metadata goes to \hypersetup.
Import reads the everyday subset: preamble \title/\author, sectioning
(\part/\chapter shift levels), paragraphs, lists with labels, quote,
alignment environments, verbatim-like environments, tabular with
\multicolumn/\multirow/\cellcolor and p{} widths, inline and display
math, formatting commands and declarations, colours, links, refs,
citations, footnotes, accents, TeX ligatures and special characters.
Unknown commands keep their arguments' text; layout and definition
commands are dropped with their arguments. Nesting is depth-limited and
input is capped, so hostile files can't overflow the stack.

The flow model gains an Equation inline so equations survive LaTeX round
trips; the other text formats write their linear text as before.

Wired into open/save dispatch, the file dialog filter, Backstage Export,
the web app, CLI/MCP help, docs and the Linux desktop entry.

Fixes #7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FczntrDNYd6DupyBdpbL5

* #7: bound LaTeX column specs and equation export (review)

- spec_widths: `*{n}{…}` repeats recurse with a depth argument and stop at
  MAX_NEST, so a deeply nested column spec no longer overflows the stack or
  exhausts memory. Column counts saturate and widths stop at MAX_COLS.
- Column specs read from the source are capped at MAX_SPEC (4096) chars.
- balanced() looks groups up in a table of closers built in one stack pass,
  so linear_to_latex no longer rescans to the end of the string for every
  unmatched `(` (quadratic: 50k took seconds). spec_widths uses it too.
- env_name restores the position when `\begin{` / `\end{` has no `}`
  within 64 chars, so the `{` is no longer lost.
- Regression tests: the 100k-deep `*{1}{` spec and a 200k-`(` equation in
  deep_nesting_is_bounded; unclosed_begin_keeps_its_brace and
  spec_widths_repeats_are_bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD

* #7: linear-time labels, brackets and \begin{; no commands from the document in exports

Applies @HaringIbon's review suggestions:

1. Parser::anchor keeps at most MAX_ANCHOR_RUN (64) bookmarks in a row with
   no text between them; text() looked back past every one, so a file of
   labels imported in quadratic time. Labels separated by text are all kept.
2. linear_to_latex_at checks for the `/` of a fraction before copying the
   group out, so deeply nested matched brackets export in linear time.
3. env_name searches only the next 65 bytes for `}`, so a run of unclosed
   `\begin{` imports in linear time.
4. An equation's `\name` is written as a command only when it is a known
   symbol, function or font/spacing command (MATH_COMMANDS); anything else
   becomes `\backslash name`. Two carets are never written side by side in
   equations or anchor names, since TeX reads `^^5c` as a backslash. Compiling
   an export can no longer `\input` files or `\write18` named by the document.

Regression tests: three cases added to deep_nesting_is_bounded, plus
latex_export_carries_no_commands_from_the_document and
latex_labels_in_a_row_are_read_in_linear_time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD

* Merge follow-ups: FTable.borderless (from #102) in the LaTeX importer; rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:57:01 -04:00

284 lines
12 KiB
Rust

//! MCP tools: names, schemas and implementations on top of a [`Backend`].
use serde_json::{Value, json};
use crate::backend::Backend;
#[derive(Clone, Debug, PartialEq)]
pub struct ToolResult {
pub content: Vec<Value>,
pub is_error: bool,
}
impl ToolResult {
pub fn text(t: impl Into<String>) -> Self {
Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: false }
}
pub fn json(v: &Value) -> Self {
Self::text(serde_json::to_string_pretty(v).unwrap_or_default())
}
pub fn error(t: impl Into<String>) -> Self {
Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: true }
}
pub fn image(png_base64: String, info: &Value) -> Self {
Self {
content: vec![json!({"type": "image", "data": png_base64, "mimeType": "image/png"}), json!({"type": "text", "text": info.to_string()})],
is_error: false,
}
}
pub fn to_value(&self) -> Value {
json!({"content": self.content, "isError": self.is_error})
}
}
fn s(desc: &str) -> Value {
json!({"type": "string", "description": desc})
}
fn n(desc: &str) -> Value {
json!({"type": "number", "description": desc})
}
fn obj(props: Value, req: &[&str]) -> Value {
json!({"type": "object", "properties": props, "required": req})
}
fn tool(name: &str, title: &str, desc: &str, schema: Value, ro: bool) -> Value {
json!({"name": name, "title": title, "description": desc, "inputSchema": schema, "annotations": {"title": title, "readOnlyHint": ro, "openWorldHint": false}})
}
pub fn tool_definitions() -> Value {
json!([
tool(
"list_commands",
"List commands",
"Every WordCraft command: id, label, ribbon location, shortcut and params. Filter with `query`.",
obj(json!({"query": s("Case-insensitive filter on id/label/location")}), &[]),
true
),
tool(
"execute",
"Run a command",
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}.",
obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"]),
false
),
tool(
"batch",
"Run several commands",
"Run commands in order; stops at the first error unless keepGoing.",
obj(
json!({"commands": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}, "keepGoing": {"type": "boolean"}}),
&["commands"]
),
false
),
tool(
"new_document",
"New document",
"Start a document: blank, sample, letter, resume or report.",
obj(json!({"template": s("blank|sample|letter|resume|report")}), &[]),
false
),
tool(
"open_document",
"Open",
"Open a .docx, .odt, .rtf, .md, .tex, .html, .txt or .json file.",
obj(json!({"path": s("File path")}), &["path"]),
false
),
tool(
"save_document",
"Save",
"Save (format from the extension: docx, pdf, odt, rtf, html, md, tex, txt, png).",
obj(json!({"path": s("File path; omit to save in place")}), &[]),
false
),
tool(
"type_text",
"Type text",
"Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.",
obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]),
false
),
tool(
"select_text",
"Select text",
"Select the n-th occurrence of some text.",
obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"]),
false
),
tool("get_text", "Document text", "Plain text of the document body.", obj(json!({}), &[]), true),
tool(
"inspect_document",
"Inspect document",
"Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts, selection, pages.",
obj(json!({}), &[]),
true
),
tool(
"render_page",
"Render page",
"Render a page to PNG to look at the result.",
obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[]),
true
),
tool("parity", "Feature parity", "WordCraft's command coverage of the word-processor feature catalog.", obj(json!({}), &[]), true),
tool("screenshot", "Screenshot app", "Screenshot of the whole WordCraft window (desktop app only).", obj(json!({}), &[]), true),
tool(
"click",
"Click",
"Click at window coordinates (desktop app only).",
obj(json!({"x": n("x"), "y": n("y"), "count": n("1-3 clicks"), "shift": {"type": "boolean"}, "cmd": {"type": "boolean"}}), &["x", "y"]),
false
),
tool(
"key",
"Press key",
"Press a key with modifiers (desktop app only), e.g. {key:\"B\", cmd:true}.",
obj(json!({"key": s("Key name"), "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}}), &["key"]),
false
),
tool(
"ui_inspect",
"Inspect UI",
"UI state: tab, panes, dialog, page rectangles on screen, caret (desktop app only).",
obj(json!({}), &[]),
true
),
])
}
fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result<Value, String> {
b.call("engine.execute", json!({"command": id, "params": params}))
}
fn wrap(r: Result<Value, String>) -> ToolResult {
match r {
Ok(v) => ToolResult::json(&v),
Err(e) => ToolResult::error(e),
}
}
pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
let st = |k: &str| a.get(k).and_then(Value::as_str);
match name {
"list_commands" => {
let q = st("query").unwrap_or("").to_lowercase();
match b.call("engine.commands", json!({})) {
Ok(Value::Array(v)) => {
let f: Vec<Value> = v
.into_iter()
.filter(|c| {
q.is_empty()
|| ["id", "label", "location"]
.iter()
.any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|x| x.to_lowercase().contains(&q)))
})
.collect();
ToolResult::json(&Value::Array(f))
}
Ok(v) => ToolResult::json(&v),
Err(e) => ToolResult::error(e),
}
}
"execute" => match st("command") {
Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})))),
None => ToolResult::error("missing `command`"),
},
"batch" => {
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
let mut out = Vec::new();
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({})));
let failed = r.is_err();
out.push(match r {
Ok(v) => json!({"command": id, "ok": true, "result": v}),
Err(e) => json!({"command": id, "ok": false, "error": e}),
});
if failed && !keep {
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
}
ToolResult::json(&Value::Array(out))
}
"new_document" => wrap(exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}))),
"open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or("")}))),
"save_document" => {
let p = st("path");
let ext = p.and_then(|x| x.rsplit('.').next()).unwrap_or("").to_ascii_lowercase();
if ext == "png" {
wrap(exec(b, "file.exportPng", json!({"path": p})))
} else {
wrap(exec(
b,
"file.save",
match p {
Some(p) => json!({"path": p}),
None => json!({}),
},
))
}
}
"type_text" => {
let t = st("text").unwrap_or("");
if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) {
let mut last = Ok(Value::Null);
for (i, line) in t.split('\n').enumerate() {
if i > 0 {
last = exec(b, "text.newParagraph", json!({}));
}
if !line.is_empty() {
last = exec(b, "text.insert", json!({"text": line, "raw": true}));
}
if last.is_err() {
break;
}
}
wrap(last)
} else {
wrap(exec(b, "text.insert", json!({"text": t, "raw": true})))
}
}
"select_text" => {
wrap(exec(b, "select.text", json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))})))
}
"get_text" => wrap(exec(b, "document.text", json!({}))),
"inspect_document" => wrap(b.call("document.inspect", json!({}))),
"parity" => wrap(b.call("ui.parity", json!({}))),
"render_page" => {
if b.has_ui() {
let path = std::env::temp_dir().join("wordcraft-mcp-page.png");
let path_s = path.to_string_lossy().to_string();
match b.call("ui.render", json!({"path": path_s, "page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))})) {
Ok(info) => match std::fs::read(&path) {
Ok(bytes) => ToolResult::image(wordcraft_engine::cmd::insert::base64_encode(&bytes), &info),
Err(e) => ToolResult::error(e.to_string()),
},
Err(e) => ToolResult::error(e),
}
} else {
match b.call("ui.render", a.clone()) {
Ok(v) => {
let png = v.get("png").and_then(Value::as_str).unwrap_or("").to_string();
ToolResult::image(png, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]}))
}
Err(e) => ToolResult::error(e),
}
}
}
"screenshot" => {
let path = std::env::temp_dir().join("wordcraft-mcp-shot.png");
match b.call("ui.screenshot", json!({"path": path.to_string_lossy()})) {
Ok(info) => match std::fs::read(&path) {
Ok(bytes) => ToolResult::image(wordcraft_engine::cmd::insert::base64_encode(&bytes), &info),
Err(e) => ToolResult::error(e.to_string()),
},
Err(e) => ToolResult::error(e),
}
}
"click" => wrap(b.call("ui.click", a.clone())),
"key" => wrap(b.call("ui.key", a.clone())),
"ui_inspect" => wrap(b.call("ui.inspect", json!({}))),
other => ToolResult::error(format!("unknown tool `{other}`")),
}
}