feat(mcp): introduce simple range model and selector-based editing
- RangeId for paragraphs, headings, and table cells - Tools: get_outline, get_ranges, replace_range_text, set_table_cell_text - Keeps edits idempotent and precise for AI workflows
This commit is contained in:
@@ -68,6 +68,14 @@ pub struct DocxHandler {
|
||||
in_memory_ops: std::collections::HashMap<String, Vec<DocxOp>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum RangeId {
|
||||
Paragraph { index: usize },
|
||||
Heading { index: usize },
|
||||
TableCell { table_index: usize, row: usize, col: usize },
|
||||
}
|
||||
|
||||
impl DocxHandler {
|
||||
pub fn new() -> Result<Self> {
|
||||
let base = std::env::var_os("DOCX_MCP_TEMP").map(PathBuf::from).unwrap_or_else(|| std::env::temp_dir());
|
||||
@@ -609,6 +617,129 @@ impl DocxHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Outline with stable indices for headings (range_ids)
|
||||
pub fn get_outline(&self, doc_id: &str) -> Result<serde_json::Value> {
|
||||
let ops = self.in_memory_ops.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?;
|
||||
let mut outline = Vec::new();
|
||||
let mut heading_idx = 0usize;
|
||||
for op in ops.iter() {
|
||||
if let DocxOp::Heading { text, style } = op {
|
||||
let level = style.chars().last().and_then(|c| c.to_digit(10)).map(|d| d as usize).unwrap_or(1);
|
||||
outline.push(serde_json::json!({
|
||||
"text": text,
|
||||
"level": level,
|
||||
"range_id": RangeId::Heading { index: heading_idx }
|
||||
}));
|
||||
heading_idx += 1;
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({"outline": outline}))
|
||||
}
|
||||
|
||||
/// Simple selector to ranges. Supported selectors:
|
||||
/// - heading:'Text'
|
||||
/// - paragraph[INDEX]
|
||||
/// - table[T].cell[R,C]
|
||||
pub fn get_ranges(&self, doc_id: &str, selector: &str) -> Result<Vec<RangeId>> {
|
||||
let ops = self.in_memory_ops.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?;
|
||||
let mut results = Vec::new();
|
||||
if let Some(rest) = selector.strip_prefix("heading:") {
|
||||
let needle = rest.trim().trim_matches('\'').trim_matches('"');
|
||||
let mut idx = 0usize;
|
||||
for op in ops.iter() {
|
||||
if let DocxOp::Heading { text, .. } = op {
|
||||
if text == needle { results.push(RangeId::Heading { index: idx }); }
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
return Ok(results);
|
||||
}
|
||||
if let Some(start) = selector.strip_prefix("paragraph[") {
|
||||
if let Some(endpos) = start.find(']') {
|
||||
if let Ok(pi) = start[..endpos].parse::<usize>() {
|
||||
results.push(RangeId::Paragraph { index: pi });
|
||||
return Ok(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(start) = selector.strip_prefix("table[") {
|
||||
if let Some(endt) = start.find(']') {
|
||||
let t_str = &start[..endt];
|
||||
if let Some(cell_part) = start[endt+1..].strip_prefix(".cell[") {
|
||||
if let Some(endc) = cell_part.find(']') {
|
||||
let coords = &cell_part[..endc];
|
||||
let mut it = coords.split(',');
|
||||
if let (Ok(ti), Some(rs), Some(cs)) = (
|
||||
t_str.parse::<usize>(),
|
||||
it.next(), it.next()
|
||||
) {
|
||||
if let (Ok(r), Ok(c)) = (rs.trim().parse::<usize>(), cs.trim().parse::<usize>()) {
|
||||
results.push(RangeId::TableCell { table_index: ti, row: r, col: c });
|
||||
return Ok(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Replace text in a given range id (paragraph or heading). For TableCell use set_table_cell_text
|
||||
pub fn replace_range_text(&mut self, doc_id: &str, range: &RangeId, new_text: &str) -> Result<()> {
|
||||
self.ensure_modifiable(doc_id)?;
|
||||
let ops = self.in_memory_ops.get_mut(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?;
|
||||
match range {
|
||||
RangeId::Paragraph { index } => {
|
||||
let mut para_idx = 0usize;
|
||||
for op in ops.iter_mut() {
|
||||
if let DocxOp::Paragraph { text, .. } = op {
|
||||
if ¶_idx == index { *text = new_text.to_string(); break; }
|
||||
para_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
RangeId::Heading { index } => {
|
||||
let mut h_idx = 0usize;
|
||||
for op in ops.iter_mut() {
|
||||
if let DocxOp::Heading { text, .. } = op {
|
||||
if &h_idx == index { *text = new_text.to_string(); break; }
|
||||
h_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
RangeId::TableCell { .. } => anyhow::bail!("Use set_table_cell_text for table cells"),
|
||||
}
|
||||
self.write_docx(doc_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set table cell text by table index and coordinates
|
||||
pub fn set_table_cell_text(&mut self, doc_id: &str, table_index: usize, row: usize, col: usize, text: &str) -> Result<()> {
|
||||
self.ensure_modifiable(doc_id)?;
|
||||
let ops = self.in_memory_ops.get_mut(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?;
|
||||
let mut ti = 0usize;
|
||||
for op in ops.iter_mut() {
|
||||
if let DocxOp::Table { data } = op {
|
||||
if ti == table_index {
|
||||
if row < data.rows.len() && col < data.rows[row].len() {
|
||||
data.rows[row][col] = text.to_string();
|
||||
self.write_docx(doc_id)?;
|
||||
return Ok(());
|
||||
} else {
|
||||
anyhow::bail!("Cell out of bounds");
|
||||
}
|
||||
}
|
||||
ti += 1;
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Table not found")
|
||||
}
|
||||
|
||||
pub fn extract_text(&self, doc_id: &str) -> Result<String> {
|
||||
let _metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
@@ -734,6 +734,46 @@ impl DocxToolsProvider {
|
||||
}),
|
||||
annotations: None,
|
||||
},
|
||||
Tool {
|
||||
name: "get_outline".to_string(),
|
||||
description: Some("Return heading outline with range_ids".to_string()),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {"document_id": {"type": "string"}},
|
||||
"required": ["document_id"]
|
||||
}),
|
||||
annotations: None,
|
||||
},
|
||||
Tool {
|
||||
name: "get_ranges".to_string(),
|
||||
description: Some("Resolve a selector to range_ids (heading:'Text', paragraph[i], table[t].cell[r,c])".to_string()),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {"document_id": {"type": "string"}, "selector": {"type": "string"}},
|
||||
"required": ["document_id", "selector"]
|
||||
}),
|
||||
annotations: None,
|
||||
},
|
||||
Tool {
|
||||
name: "replace_range_text".to_string(),
|
||||
description: Some("Replace text in a paragraph/heading by range_id".to_string()),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {"document_id": {"type": "string"}, "range_id": {"type": "object"}, "text": {"type": "string"}},
|
||||
"required": ["document_id", "range_id", "text"]
|
||||
}),
|
||||
annotations: None,
|
||||
},
|
||||
Tool {
|
||||
name: "set_table_cell_text".to_string(),
|
||||
description: Some("Set text in a table cell by indices".to_string()),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {"document_id": {"type": "string"}, "table_index": {"type": "integer"}, "row": {"type": "integer"}, "col": {"type": "integer"}, "text": {"type": "string"}},
|
||||
"required": ["document_id", "table_index", "row", "col", "text"]
|
||||
}),
|
||||
annotations: None,
|
||||
},
|
||||
Tool {
|
||||
name: "get_document_properties".to_string(),
|
||||
description: Some("Get document properties (title, subject, author, timestamps)".to_string()),
|
||||
@@ -1452,6 +1492,51 @@ impl DocxToolsProvider {
|
||||
Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }
|
||||
}
|
||||
},
|
||||
"get_outline" => {
|
||||
let doc_id = arguments["document_id"].as_str().unwrap_or("");
|
||||
let handler = self.handler.read().unwrap();
|
||||
match handler.get_outline(doc_id) {
|
||||
Ok(outline) => ToolOutcome::Metadata { metadata: outline },
|
||||
Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None },
|
||||
}
|
||||
},
|
||||
"get_ranges" => {
|
||||
let doc_id = arguments["document_id"].as_str().unwrap_or("");
|
||||
let selector = arguments["selector"].as_str().unwrap_or("");
|
||||
let handler = self.handler.read().unwrap();
|
||||
match handler.get_ranges(doc_id, selector) {
|
||||
Ok(ranges) => ToolOutcome::Metadata { metadata: serde_json::json!({"ranges": ranges}) },
|
||||
Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None },
|
||||
}
|
||||
},
|
||||
"replace_range_text" => {
|
||||
let doc_id = arguments["document_id"].as_str().unwrap_or("");
|
||||
let range_id = arguments["range_id"].clone();
|
||||
let text = arguments["text"].as_str().unwrap_or("");
|
||||
let range: crate::docx_handler::RangeId = match serde_json::from_value(range_id) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "application/json".into(), text: serde_json::json!({"success": false, "code": ErrorCode::ValidationError, "error": format!("invalid range_id: {}", e)}).to_string(), annotations: None })], is_error: Some(true), meta: None };
|
||||
}
|
||||
};
|
||||
let mut handler = self.handler.write().unwrap();
|
||||
match handler.replace_range_text(doc_id, &range, text) {
|
||||
Ok(_) => ToolOutcome::Ok { message: Some("Range text replaced".into()) },
|
||||
Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None },
|
||||
}
|
||||
},
|
||||
"set_table_cell_text" => {
|
||||
let doc_id = arguments["document_id"].as_str().unwrap_or("");
|
||||
let ti = arguments["table_index"].as_u64().unwrap_or(0) as usize;
|
||||
let r = arguments["row"].as_u64().unwrap_or(0) as usize;
|
||||
let c = arguments["col"].as_u64().unwrap_or(0) as usize;
|
||||
let text = arguments["text"].as_str().unwrap_or("");
|
||||
let mut handler = self.handler.write().unwrap();
|
||||
match handler.set_table_cell_text(doc_id, ti, r, c, text) {
|
||||
Ok(_) => ToolOutcome::Ok { message: Some("Table cell updated".into()) },
|
||||
Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None },
|
||||
}
|
||||
},
|
||||
|
||||
"analyze_formatting" => {
|
||||
let doc_id = arguments["document_id"].as_str().unwrap_or("");
|
||||
|
||||
Reference in New Issue
Block a user