spreadsheet-mcp 0.10.1

Stateful MCP server for spreadsheet analysis and editing — token-efficient tools for LLM agents to read, profile, edit, and recalculate .xlsx workbooks
Documentation
#[cfg(not(feature = "recalc"))]
use crate::core::types::{BasicDiffChange, BasicDiffResponse};
#[cfg(not(feature = "recalc"))]
use anyhow::Context;
use anyhow::Result;
use serde_json::Value;
use std::path::Path;

#[cfg(feature = "recalc")]
pub fn calculate_changeset(
    base_path: &Path,
    fork_path: &Path,
    sheet_filter: Option<&str>,
) -> Result<Vec<crate::diff::Change>> {
    crate::diff::calculate_changeset(base_path, fork_path, sheet_filter)
}

pub fn diff_workbooks_json(original: &Path, modified: &Path) -> Result<Value> {
    #[cfg(feature = "recalc")]
    {
        let changes = calculate_changeset(original, modified, None)?;
        Ok(serde_json::json!({
            "original": original.display().to_string(),
            "modified": modified.display().to_string(),
            "change_count": changes.len(),
            "changes": changes,
        }))
    }

    #[cfg(not(feature = "recalc"))]
    {
        let response = basic_diff_workbooks(original, modified)?;
        Ok(serde_json::to_value(response)?)
    }
}

#[cfg(not(feature = "recalc"))]
#[derive(Debug, Clone)]
struct CellSnapshot {
    value: String,
    formula: Option<String>,
}

#[cfg(not(feature = "recalc"))]
fn basic_diff_workbooks(original: &Path, modified: &Path) -> Result<BasicDiffResponse> {
    use std::collections::BTreeSet;

    let original_cells = collect_cells(original)?;
    let modified_cells = collect_cells(modified)?;

    let mut keys = BTreeSet::new();
    keys.extend(original_cells.keys().cloned());
    keys.extend(modified_cells.keys().cloned());

    let mut changes = Vec::new();
    for (sheet, address) in keys {
        let original_cell = original_cells.get(&(sheet.clone(), address.clone()));
        let modified_cell = modified_cells.get(&(sheet.clone(), address.clone()));

        if cells_equal(original_cell, modified_cell) {
            continue;
        }

        let change_type = match (original_cell, modified_cell) {
            (None, Some(_)) => "added",
            (Some(_), None) => "removed",
            (Some(orig), Some(next))
                if orig.formula != next.formula && orig.value != next.value =>
            {
                "formula_and_value_changed"
            }
            (Some(orig), Some(next)) if orig.formula != next.formula => "formula_changed",
            _ => "value_changed",
        }
        .to_string();

        changes.push(BasicDiffChange {
            sheet,
            address,
            change_type,
            original_value: original_cell.map(|cell| cell.value.clone()),
            original_formula: original_cell.and_then(|cell| cell.formula.clone()),
            modified_value: modified_cell.map(|cell| cell.value.clone()),
            modified_formula: modified_cell.and_then(|cell| cell.formula.clone()),
        });
    }

    Ok(BasicDiffResponse {
        original: original.display().to_string(),
        modified: modified.display().to_string(),
        change_count: changes.len(),
        changes,
    })
}

#[cfg(not(feature = "recalc"))]
fn collect_cells(
    path: &Path,
) -> Result<std::collections::BTreeMap<(String, String), CellSnapshot>> {
    let book = umya_spreadsheet::reader::xlsx::read(path)
        .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
    let mut cells = std::collections::BTreeMap::new();

    for sheet in book.get_sheet_collection() {
        let sheet_name = sheet.get_name().to_string();
        for cell in sheet.get_cell_collection() {
            let address = cell.get_coordinate().get_coordinate().to_string();
            let value = cell.get_value().to_string();
            let formula = if cell.is_formula() {
                Some(cell.get_formula().to_string())
            } else {
                None
            };

            cells.insert(
                (sheet_name.clone(), address),
                CellSnapshot { value, formula },
            );
        }
    }

    Ok(cells)
}

#[cfg(not(feature = "recalc"))]
fn cells_equal(left: Option<&CellSnapshot>, right: Option<&CellSnapshot>) -> bool {
    match (left, right) {
        (None, None) => true,
        (Some(a), Some(b)) => a.value == b.value && a.formula == b.formula,
        _ => false,
    }
}