From 25e4986998f65f119b7957f7d4cdcac883ca460e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 22:27:46 +0200 Subject: [PATCH] MCP: insert_table tool; insert.spreadsheet takes values, header and style (#2242) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/tools.rs | 71 ++++++++++++++++++++++++++++++++-- crates/mcp/src/tests.rs | 41 ++++++++++++++++++++ crates/mcp/src/tools.rs | 28 +++++++++++++- docs/mcp.md | 3 +- 5 files changed, 138 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40407d18b6..91e8e6b177 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-16 | MCP `insert_table` tool (#2242): a filled table in one call from `values`, with `header` (repeating first row) and `style`; `insert.spreadsheet` takes the same `values`/`header`/`style` and rejects ragged, oversized or non-array input | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 1243ba4f24..8035e5cb9b 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -510,10 +510,10 @@ pub fn specs() -> Vec { }) .params(r#"{"path": string}"#), CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| { - let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6"); - let rows = super::mailings::parse_csv(csv); + let rows = spreadsheet_rows(v)?; let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63); - s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?; + let style = p::str(v, "style").unwrap_or("GridTable1Light"); + s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?; let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) }; let t = s.doc.table_mut(s.sel.focus.story, &tp)?; for (r, row) in rows.iter().enumerate() { @@ -523,9 +523,14 @@ pub fn specs() -> Vec { } } } + if p::bool(v, "header").unwrap_or(false) + && let Some(first) = t.rows.first_mut() + { + first.props.header = true; + } sel_result(s) }) - .params(r#"{"csv"?: string}"#), + .params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#), CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| { let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true); let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; @@ -1764,6 +1769,39 @@ fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult { Ok(Value::Object(out)) } +/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`. +fn spreadsheet_rows(v: &Value) -> Result>, CmdError> { + let Some(values) = v.get("values").filter(|x| !x.is_null()) else { + return Ok(super::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6"))); + }; + let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?; + if list.is_empty() || list.len() > 1000 { + return Err(CmdError::Params("`values` needs 1 to 1000 rows".into())); + } + let mut rows: Vec> = Vec::with_capacity(list.len()); + for (r, row) in list.iter().enumerate() { + let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?; + if cells.is_empty() || cells.len() > 63 { + return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1))); + } + if rows.first().is_some_and(|f| f.len() != cells.len()) { + return Err(CmdError::Params(format!("`values` row {} has {} cells, row 1 has {}", r + 1, cells.len(), rows.first().map_or(0, Vec::len)))); + } + let mut out = Vec::with_capacity(cells.len()); + for cell in cells { + out.push(match cell { + Value::String(x) => x.clone(), + Value::Number(x) => x.to_string(), + Value::Bool(x) => x.to_string(), + Value::Null => String::new(), + _ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))), + }); + } + rows.push(out); + } + Ok(rows) +} + #[cfg(test)] mod tests { use super::*; @@ -1866,4 +1904,29 @@ mod tests { assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE")); assert!(s.run("review.language", &json!({"lang": "