excel-mcp-server 0.1.0

An MCP server that exposes Excel manipulation tools over stdio and streamable HTTP transports
Documentation
use super::common::workbook_not_found;
use crate::engines::zavora;
use crate::store::WorkbookStore;
use crate::types::inputs::*;
use crate::types::responses::*;

pub fn read_sheet(
    store: &mut WorkbookStore,
    input: ReadSheetInput,
) -> Result<String, anyhow::Error> {
    let entry = match store.get_mut(&input.workbook_id) {
        Some(e) => e,
        None => return Ok(workbook_not_found(store, &input.workbook_id)),
    };
    let idx = match find_sheet(&entry.data, &input.sheet_name) {
        Some(i) => i,
        None => return Ok(sheet_err(&input.sheet_name)),
    };
    let ws = entry
        .data
        .worksheet(idx)
        .map_err(|e| anyhow::anyhow!("{e}"))?;
    let (r1, c1, r2, c2) = if let Some(ref range_str) = input.range {
        zavora_xlsx::utility::parse_range_ref(range_str).map_err(|e| anyhow::anyhow!("{e}"))?
    } else {
        match ws.used_range() {
            Some(r) => r,
            None => {
                return Ok(success(
                    "Sheet is empty",
                    ReadSheetData {
                        rows: vec![],
                        total_rows: 0,
                        page_rows: 0,
                        continuation_token: None,
                    },
                ))
            }
        }
    };
    let offset = input
        .continuation_token
        .as_ref()
        .and_then(|t| serde_json::from_str::<ContinuationToken>(t).ok())
        .map(|t| t.offset)
        .unwrap_or(0);
    let page_size = 100u32;
    let start = r1 + offset;
    let end = (start + page_size - 1).min(r2);
    let mut rows = Vec::new();
    for r in start..=end {
        let mut row = Vec::new();
        for c in c1..=c2 {
            row.push(zavora::cell_to_json(&ws.read_cell(r, c)));
        }
        rows.push(row);
    }
    let total = r2 - r1 + 1;
    let token = if end < r2 {
        Some(serde_json::to_string(&ContinuationToken {
            sheet: input.sheet_name.clone(),
            offset: end - r1 + 1,
            range: input.range.clone(),
        })?)
    } else {
        None
    };
    Ok(success(
        "Sheet data read",
        ReadSheetData {
            rows,
            total_rows: total,
            page_rows: end - start + 1,
            continuation_token: token,
        },
    ))
}

pub fn read_cell(store: &mut WorkbookStore, input: ReadCellInput) -> Result<String, anyhow::Error> {
    let entry = match store.get_mut(&input.workbook_id) {
        Some(e) => e,
        None => return Ok(workbook_not_found(store, &input.workbook_id)),
    };
    let idx = match find_sheet(&entry.data, &input.sheet_name) {
        Some(i) => i,
        None => return Ok(sheet_err(&input.sheet_name)),
    };
    let (row, col) =
        zavora_xlsx::utility::parse_cell_ref(&input.cell).map_err(|e| anyhow::anyhow!("{e}"))?;
    let ws = entry
        .data
        .worksheet(idx)
        .map_err(|e| anyhow::anyhow!("{e}"))?;
    let val = ws.read_cell(row, col);
    let formula = if let zavora_xlsx::CellValue::Formula { ref formula, .. } = val {
        Some(formula.clone())
    } else {
        None
    };
    Ok(success(
        "Cell read",
        CellData {
            cell: input.cell,
            value: zavora::cell_to_json(&val),
            value_type: zavora::cell_type_name(&val).to_string(),
            formula,
        },
    ))
}

pub fn search_cells(
    store: &mut WorkbookStore,
    input: SearchCellsInput,
) -> Result<String, anyhow::Error> {
    let entry = match store.get_mut(&input.workbook_id) {
        Some(e) => e,
        None => return Ok(workbook_not_found(store, &input.workbook_id)),
    };
    let names: Vec<String> = match &input.sheet_name {
        Some(n) => vec![n.clone()],
        None => entry
            .data
            .sheet_names()
            .iter()
            .map(|s| s.to_string())
            .collect(),
    };
    let mut matches = Vec::new();
    let max = 200;
    let query_lower = input.query.to_lowercase();
    let is_exact = matches!(input.match_mode, crate::types::enums::MatchMode::Exact);
    for name in &names {
        let idx = match find_sheet(&entry.data, name) {
            Some(i) => i,
            None => continue,
        };
        let ws = entry
            .data
            .worksheet(idx)
            .map_err(|e| anyhow::anyhow!("{e}"))?;
        if let Some((r1, c1, r2, c2)) = ws.used_range() {
            for r in r1..=r2 {
                for c in c1..=c2 {
                    if matches.len() >= max {
                        break;
                    }
                    let val = ws.read_cell(r, c);
                    let s = match &val {
                        zavora_xlsx::CellValue::String(s) => s.clone(),
                        zavora_xlsx::CellValue::Number(n) => format!("{n}"),
                        zavora_xlsx::CellValue::Bool(b) => b.to_string(),
                        _ => continue,
                    };
                    let hit = if is_exact {
                        s.to_lowercase() == query_lower
                    } else {
                        s.to_lowercase().contains(&query_lower)
                    };
                    if hit {
                        matches.push(SearchMatch {
                            sheet: name.clone(),
                            cell: zavora_xlsx::utility::to_a1(r, c),
                            value: zavora::cell_to_json(&val),
                        });
                    }
                }
            }
        }
    }
    let truncated = matches.len() >= max;
    let total = matches.len();
    Ok(success(
        "Search complete",
        SearchResult {
            matches,
            total_matches: total,
            truncated,
        },
    ))
}

pub fn sheet_to_csv(
    store: &mut WorkbookStore,
    input: SheetToCsvInput,
) -> Result<String, anyhow::Error> {
    let entry = match store.get_mut(&input.workbook_id) {
        Some(e) => e,
        None => return Ok(workbook_not_found(store, &input.workbook_id)),
    };
    let idx = match find_sheet(&entry.data, &input.sheet_name) {
        Some(i) => i,
        None => return Ok(sheet_err(&input.sheet_name)),
    };
    let ws = entry
        .data
        .worksheet(idx)
        .map_err(|e| anyhow::anyhow!("{e}"))?;
    let delim = input.delimiter.as_bytes().first().copied().unwrap_or(b',');
    let mut buf = Vec::new();
    ws.to_csv(&mut buf, delim)
        .map_err(|e| anyhow::anyhow!("{e}"))?;
    let csv = String::from_utf8(buf).unwrap_or_default();
    let rows = csv.lines().count() as u32;
    Ok(success(
        "CSV exported",
        CsvExportData {
            csv,
            total_rows: rows,
            truncated: false,
        },
    ))
}

fn find_sheet(wb: &zavora_xlsx::Workbook, name: &str) -> Option<usize> {
    wb.sheet_names().iter().position(|n| *n == name)
}
fn sheet_err(name: &str) -> String {
    error(
        ErrorCategory::NotFound,
        &format!("Sheet '{}' not found", name),
        "Check sheet name.",
    )
}