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;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::ErrorCode;
use std::collections::HashSet;
use std::sync::Arc;

use spreadsheet_mcp::model::SheetPageFormat;
use spreadsheet_mcp::tools::{ListWorkbooksParams, SheetPageParams};
use spreadsheet_mcp::{SpreadsheetServer, startup_scan};

mod support;

#[test]
fn startup_scan_discovers_workspace_workbooks() {
    let workspace = support::TestWorkspace::new();
    workspace.create_workbook("reports/summary.xlsx", |_| {});

    let state = workspace.app_state();
    let response = startup_scan(&state).expect("startup scan");

    assert_eq!(response.workbooks.len(), 1);
    assert_eq!(response.workbooks[0].slug, "summary");
}

#[tokio::test(flavor = "current_thread")]
async fn server_tool_handlers_return_json() -> Result<()> {
    let workspace = support::TestWorkspace::new();
    workspace.create_workbook("simple.xlsx", |book| {
        let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
        sheet.get_cell_mut((1, 1)).set_value("Name".to_string());
        sheet.get_cell_mut((2, 1)).set_value("Value".to_string());
        sheet.get_cell_mut((1, 2)).set_value("Alpha".to_string());
        sheet.get_cell_mut((2, 2)).set_value_number(10_f64);
        sheet
            .get_cell_mut((2, 3))
            .set_formula("B2*2")
            .set_formula_result_default("20");
    });

    let server = workspace.server().await?;

    let list = server
        .list_workbooks(Parameters(ListWorkbooksParams {
            slug_prefix: None,
            folder: None,
            path_glob: None,
            limit: None,
            offset: None,
            include_paths: None,
        }))
        .await
        .expect("list workbooks")
        .0;
    assert_eq!(list.0.workbooks.len(), 1);
    let workbook_id = list.0.workbooks[0].workbook_id.clone();

    let error = match server
        .sheet_page(Parameters(SheetPageParams {
            workbook_or_fork_id: workbook_id.clone(),
            sheet_name: "Missing".to_string(),
            start_row: 1,
            page_size: 10,
            columns: None,
            columns_by_header: None,
            include_formulas: true,
            include_styles: false,
            include_header: true,
            format: None,
        }))
        .await
    {
        Ok(_) => panic!("missing sheet should error"),
        Err(err) => err,
    };
    assert!(error.message.contains("sheet Missing"));

    let page = server
        .sheet_page(Parameters(SheetPageParams {
            workbook_or_fork_id: workbook_id,
            sheet_name: "Sheet1".to_string(),
            start_row: 1,
            page_size: 10,
            columns: None,
            include_formulas: true,
            include_styles: false,
            columns_by_header: None,
            include_header: true,
            format: Some(SheetPageFormat::Full),
        }))
        .await
        .expect("page fetch")
        .0;
    assert_eq!(page.0.rows.len(), 3);
    let contains_b3 = page
        .0
        .rows
        .iter()
        .flat_map(|row| row.cells.iter())
        .any(|cell| cell.address == "B3");
    assert!(contains_b3);

    Ok(())
}

#[tokio::test(flavor = "current_thread")]
async fn response_size_guard_rejects_large_payloads() -> Result<()> {
    let workspace = support::TestWorkspace::new();
    let _path = workspace.create_workbook("oversize.xlsx", |book| {
        let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
        for row in 1..=25u32 {
            sheet
                .get_cell_mut((1u32, row))
                .set_value(format!("Row{}", row));
            sheet.get_cell_mut((2u32, row)).set_value_number(row as i32);
        }
    });

    let config = workspace.config_with(|cfg| {
        cfg.max_response_bytes = Some(1);
    });
    let server = SpreadsheetServer::new(Arc::new(config)).await?;

    let err = match server
        .list_workbooks(Parameters(ListWorkbooksParams {
            slug_prefix: None,
            folder: None,
            path_glob: None,
            limit: None,
            offset: None,
            include_paths: None,
        }))
        .await
    {
        Ok(_) => panic!("expected response size error"),
        Err(err) => err,
    };

    assert_eq!(err.code, ErrorCode::INVALID_REQUEST);
    assert!(err.message.contains("response too large"));

    Ok(())
}

#[tokio::test(flavor = "current_thread")]
async fn disabled_tools_return_invalid_request() -> Result<()> {
    let workspace = support::TestWorkspace::new();
    let workbook_path = workspace.create_workbook("locked.xlsx", |_| {});

    let mut enabled = HashSet::new();
    enabled.insert("list_workbooks".to_string());

    let config = workspace.config_with(|cfg| {
        cfg.enabled_tools = Some(enabled);
        cfg.single_workbook = Some(workbook_path.clone());
    });
    let server = SpreadsheetServer::new(Arc::new(config)).await?;

    let list = server
        .list_workbooks(Parameters(ListWorkbooksParams {
            slug_prefix: None,
            folder: None,
            path_glob: None,
            limit: None,
            offset: None,
            include_paths: None,
        }))
        .await?
        .0;
    assert_eq!(list.0.workbooks.len(), 1);
    let workbook_id = list.0.workbooks[0].workbook_id.clone();

    let error = match server
        .sheet_page(Parameters(SheetPageParams {
            workbook_or_fork_id: workbook_id,
            sheet_name: "Sheet1".to_string(),
            start_row: 1,
            page_size: 5,
            columns: None,
            include_formulas: true,
            include_styles: false,
            columns_by_header: None,
            include_header: true,
            format: None,
        }))
        .await
    {
        Ok(_) => panic!("sheet_page should be disabled"),
        Err(err) => err,
    };

    assert_eq!(error.code, ErrorCode::INVALID_REQUEST);
    assert!(
        error
            .message
            .contains("tool 'sheet_page' is disabled by server configuration")
    );

    Ok(())
}