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
use anyhow::{Result, bail};
use serde_json::Value;
use std::path::PathBuf;

use crate::cli::{FindValueMode, FormulaSort, TableReadFormat, TraceDirectionArg};
use crate::model::{FindMode, TableOutputFormat, TraceDirection};
use crate::runtime::stateless::StatelessRuntime;
use crate::tools;
use crate::tools::{
    DescribeWorkbookParams, FindValueParams, FormulaSortBy, FormulaTraceParams, ListSheetsParams,
    RangeValuesParams, ReadTableParams, SheetFormulaMapParams, SheetOverviewParams,
    TableProfileParams,
};

pub async fn list_sheets(file: PathBuf) -> Result<Value> {
    let runtime = StatelessRuntime;
    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
    let response = tools::list_sheets(
        state,
        ListSheetsParams {
            workbook_or_fork_id: workbook_id,
            limit: None,
            offset: None,
            include_bounds: None,
        },
    )
    .await?;
    Ok(serde_json::to_value(response)?)
}

pub async fn sheet_overview(file: PathBuf, sheet: String) -> Result<Value> {
    let runtime = StatelessRuntime;
    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
    let response = tools::sheet_overview(
        state,
        SheetOverviewParams {
            workbook_or_fork_id: workbook_id,
            sheet_name: sheet,
            max_regions: None,
            max_headers: None,
            include_headers: None,
        },
    )
    .await?;
    Ok(serde_json::to_value(response)?)
}

pub async fn range_values(file: PathBuf, sheet: String, ranges: Vec<String>) -> Result<Value> {
    if ranges.is_empty() {
        bail!("at least one range must be provided");
    }
    let runtime = StatelessRuntime;
    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
    let response = tools::range_values(
        state,
        RangeValuesParams {
            workbook_or_fork_id: workbook_id,
            sheet_name: sheet,
            ranges,
            include_headers: None,
            format: Some(TableOutputFormat::Json),
            page_size: None,
        },
    )
    .await?;
    Ok(serde_json::to_value(response)?)
}

pub async fn describe(file: PathBuf) -> Result<Value> {
    let runtime = StatelessRuntime;
    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
    let response = tools::describe_workbook(
        state,
        DescribeWorkbookParams {
            workbook_or_fork_id: workbook_id,
        },
    )
    .await?;
    Ok(serde_json::to_value(response)?)
}

pub async fn read_table(
    file: PathBuf,
    sheet: Option<String>,
    range: Option<String>,
    format: Option<TableReadFormat>,
) -> Result<Value> {
    let runtime = StatelessRuntime;
    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
    let sheet_name = match sheet {
        Some(name) => Some(resolve_sheet_name(&state, &workbook_id, &name).await?),
        None => None,
    };
    let response = tools::read_table(
        state,
        ReadTableParams {
            workbook_or_fork_id: workbook_id,
            sheet_name,
            table_name: None,
            region_id: None,
            range,
            header_row: None,
            header_rows: None,
            columns: None,
            filters: None,
            sample_mode: None,
            limit: None,
            offset: None,
            format: format.map(map_table_read_format),
            include_headers: None,
            include_types: None,
        },
    )
    .await?;
    Ok(serde_json::to_value(response)?)
}

pub async fn find_value(
    file: PathBuf,
    query: String,
    sheet: Option<String>,
    mode: Option<FindValueMode>,
) -> Result<Value> {
    let runtime = StatelessRuntime;
    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
    let sheet_name = match sheet {
        Some(name) => Some(resolve_sheet_name(&state, &workbook_id, &name).await?),
        None => None,
    };
    let response = tools::find_value(
        state,
        FindValueParams {
            workbook_or_fork_id: workbook_id,
            query,
            mode: mode.map(map_find_value_mode),
            sheet_name,
            ..FindValueParams::default()
        },
    )
    .await?;
    Ok(serde_json::to_value(response)?)
}

pub async fn formula_map(
    file: PathBuf,
    sheet: String,
    limit: Option<u32>,
    sort_by: Option<FormulaSort>,
) -> Result<Value> {
    let runtime = StatelessRuntime;
    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
    let response = tools::sheet_formula_map(
        state,
        SheetFormulaMapParams {
            workbook_or_fork_id: workbook_id,
            sheet_name: sheet,
            range: None,
            expand: false,
            limit,
            sort_by: sort_by.map(map_formula_sort),
            summary_only: None,
            include_addresses: None,
            addresses_limit: None,
        },
    )
    .await?;
    Ok(serde_json::to_value(response)?)
}

pub async fn formula_trace(
    file: PathBuf,
    sheet: String,
    cell: String,
    direction: TraceDirectionArg,
) -> Result<Value> {
    let runtime = StatelessRuntime;
    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
    let sheet = resolve_sheet_name(&state, &workbook_id, &sheet).await?;
    let response = tools::formula_trace(
        state,
        FormulaTraceParams {
            workbook_or_fork_id: workbook_id,
            sheet_name: sheet,
            cell_address: cell,
            direction: map_trace_direction(direction),
            depth: None,
            limit: None,
            page_size: None,
            cursor: None,
        },
    )
    .await?;
    Ok(serde_json::to_value(response)?)
}

pub async fn table_profile(file: PathBuf, sheet: Option<String>) -> Result<Value> {
    let runtime = StatelessRuntime;
    let (state, workbook_id) = runtime.open_state_for_file(&file).await?;
    let sheet_name = match sheet {
        Some(name) => Some(resolve_sheet_name(&state, &workbook_id, &name).await?),
        None => None,
    };
    let response = tools::table_profile(
        state,
        TableProfileParams {
            workbook_or_fork_id: workbook_id,
            sheet_name,
            region_id: None,
            table_name: None,
            sample_mode: None,
            sample_size: None,
            summary_only: None,
        },
    )
    .await?;
    Ok(serde_json::to_value(response)?)
}

fn map_table_read_format(format: TableReadFormat) -> TableOutputFormat {
    match format {
        TableReadFormat::Json => TableOutputFormat::Json,
        TableReadFormat::Values => TableOutputFormat::Values,
        TableReadFormat::Csv => TableOutputFormat::Csv,
    }
}

fn map_find_value_mode(mode: FindValueMode) -> FindMode {
    match mode {
        FindValueMode::Value => FindMode::Value,
        FindValueMode::Label => FindMode::Label,
    }
}

fn map_formula_sort(sort: FormulaSort) -> FormulaSortBy {
    match sort {
        FormulaSort::Complexity => FormulaSortBy::Complexity,
        FormulaSort::Count => FormulaSortBy::Count,
    }
}

fn map_trace_direction(direction: TraceDirectionArg) -> TraceDirection {
    match direction {
        TraceDirectionArg::Precedents => TraceDirection::Precedents,
        TraceDirectionArg::Dependents => TraceDirection::Dependents,
    }
}

async fn resolve_sheet_name(
    state: &std::sync::Arc<crate::state::AppState>,
    workbook_id: &crate::model::WorkbookId,
    requested: &str,
) -> Result<String> {
    let response = tools::list_sheets(
        state.clone(),
        ListSheetsParams {
            workbook_or_fork_id: workbook_id.clone(),
            limit: None,
            offset: None,
            include_bounds: None,
        },
    )
    .await?;

    let Some(exact) = response.sheets.iter().find(|entry| entry.name == requested) else {
        if let Some(case_insensitive) = response
            .sheets
            .iter()
            .find(|entry| entry.name.eq_ignore_ascii_case(requested))
        {
            return Ok(case_insensitive.name.clone());
        }

        let best = response
            .sheets
            .iter()
            .min_by_key(|entry| levenshtein(requested, &entry.name))
            .map(|entry| entry.name.clone());
        if let Some(suggestion) = best {
            bail!(
                "sheet '{}' not found; did you mean '{}' ?",
                requested,
                suggestion
            );
        }
        bail!("sheet '{}' not found", requested);
    };

    Ok(exact.name.clone())
}

fn levenshtein(left: &str, right: &str) -> usize {
    if left == right {
        return 0;
    }
    if left.is_empty() {
        return right.chars().count();
    }
    if right.is_empty() {
        return left.chars().count();
    }

    let left_chars = left.chars().collect::<Vec<_>>();
    let right_chars = right.chars().collect::<Vec<_>>();

    let mut prev = (0..=right_chars.len()).collect::<Vec<_>>();
    let mut curr = vec![0usize; right_chars.len() + 1];

    for (i, lc) in left_chars.iter().enumerate() {
        curr[0] = i + 1;
        for (j, rc) in right_chars.iter().enumerate() {
            let cost = usize::from(lc != rc);
            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
        }
        std::mem::swap(&mut prev, &mut curr);
    }

    prev[right_chars.len()]
}