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
//! Docker E2E tests for workbook_style_summary (Phase 1).

use anyhow::Result;
use serde_json::json;
use std::collections::HashSet;
use umya_spreadsheet::{
    ConditionalFormatValues, ConditionalFormatting, ConditionalFormattingRule, Formula,
};

use crate::support::mcp::{McpTestClient, call_tool, extract_json};

#[tokio::test]
async fn test_workbook_style_summary_reports_theme_and_conditional_formats_in_docker() -> Result<()>
{
    let test = McpTestClient::new();
    test.workspace().create_workbook("wb_styles.xlsx", |book| {
        let sheet1 = book.get_sheet_by_name_mut("Sheet1").unwrap();
        for col in ['A', 'B', 'C'] {
            let addr = format!("{col}1");
            sheet1.get_cell_mut(addr.as_str()).set_value("x");
        }
        sheet1.get_cell_mut("D1").set_value("Header");
        sheet1.get_style_mut("D1").get_font_mut().set_bold(true);

        book.new_sheet("Sheet2").unwrap();
        let sheet2 = book.get_sheet_by_name_mut("Sheet2").unwrap();
        sheet2.get_cell_mut("A1").set_value_number(1);

        let mut cf = ConditionalFormatting::default();
        cf.get_sequence_of_references_mut().set_sqref("A1:A3");
        let mut rule = ConditionalFormattingRule::default();
        rule.set_type(ConditionalFormatValues::Expression);
        rule.set_priority(1);
        let mut formula = Formula::default();
        formula.set_string_value("A1>0");
        rule.set_formula(formula);
        cf.add_conditional_collection(rule);
        sheet2.add_conditional_formatting_collection(cf);
    });

    let client = test.connect().await?;
    let workbooks = extract_json(
        &client
            .call_tool(call_tool("list_workbooks", json!({})))
            .await?,
    )?;
    let workbook_id = workbooks["workbooks"][0]["workbook_id"].as_str().unwrap();

    let summary = extract_json(
        &client
            .call_tool(call_tool(
                "workbook_style_summary",
                json!({ "workbook_or_fork_id": workbook_id, "summary_only": false }),
            ))
            .await?,
    )?;

    assert!(
        summary["theme"]["colors"]
            .as_object()
            .map(|m| !m.is_empty())
            .unwrap_or(false),
        "expected theme colors"
    );
    assert!(
        summary["styles"]
            .as_array()
            .map(|a| !a.is_empty())
            .unwrap_or(false),
        "expected styles"
    );

    let cf = summary["conditional_formats"].as_array().unwrap();
    assert_eq!(cf.len(), 1);
    assert_eq!(cf[0]["sheet_name"].as_str().unwrap(), "Sheet2");

    client.cancel().await?;
    Ok(())
}

#[tokio::test]
async fn test_workbook_style_summary_works_on_forks_in_docker() -> Result<()> {
    let test = McpTestClient::new();
    test.workspace()
        .create_workbook("fork_styles.xlsx", |book| {
            let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
            sheet.get_cell_mut("A1").set_value("x");
            sheet.get_cell_mut("A2").set_value("y");
        });

    let client = test.connect().await?;
    let workbooks = extract_json(
        &client
            .call_tool(call_tool("list_workbooks", json!({})))
            .await?,
    )?;
    let workbook_id = workbooks["workbooks"][0]["workbook_id"].as_str().unwrap();

    let base_summary = extract_json(
        &client
            .call_tool(call_tool(
                "workbook_style_summary",
                json!({ "workbook_or_fork_id": workbook_id }),
            ))
            .await?,
    )?;

    let base_ids: HashSet<String> = base_summary["styles"]
        .as_array()
        .unwrap_or(&Vec::new())
        .iter()
        .filter_map(|s| s["style_id"].as_str().map(|v| v.to_string()))
        .collect();

    let fork = extract_json(
        &client
            .call_tool(call_tool(
                "create_fork",
                json!({ "workbook_or_fork_id": workbook_id }),
            ))
            .await?,
    )?;
    let fork_id = fork["fork_id"].as_str().unwrap();

    let _ = client
        .call_tool(call_tool(
            "style_batch",
            json!({
              "fork_id": fork_id,
              "mode": "apply",
              "ops": [{
                "sheet_name": "Sheet1",
                "target": { "kind": "cells", "cells": ["A2"] },
                "patch": { "font": { "italic": true } },
                "op_mode": "merge"
              }]
            }),
        ))
        .await?;

    let fork_summary = extract_json(
        &client
            .call_tool(call_tool(
                "workbook_style_summary",
                json!({ "workbook_or_fork_id": fork_id }),
            ))
            .await?,
    )?;

    let fork_ids: HashSet<String> = fork_summary["styles"]
        .as_array()
        .unwrap_or(&Vec::new())
        .iter()
        .filter_map(|s| s["style_id"].as_str().map(|v| v.to_string()))
        .collect();

    assert!(
        fork_ids.difference(&base_ids).next().is_some(),
        "expected fork to introduce a new style"
    );

    client.cancel().await?;
    Ok(())
}