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 chrono::{DateTime, SecondsFormat, Utc};
use rand::Rng;
use sha2::{Digest, Sha256};
use std::fs::{File, Metadata};
use std::io::{BufReader, Read};
use std::path::Path;
use std::time::SystemTime;

pub fn system_time_to_datetime(time: SystemTime) -> Option<DateTime<Utc>> {
    Some(DateTime::<Utc>::from(time))
}

pub fn system_time_to_rfc3339(time: SystemTime) -> Option<DateTime<Utc>> {
    system_time_to_datetime(time)
}

fn hash_path_metadata_digest(path: &Path, metadata: &Metadata) -> [u8; 32] {
    let mut hasher = Sha256::new();
    hasher.update(path.to_string_lossy().as_bytes());
    hasher.update(metadata.len().to_le_bytes());
    if let Ok(modified) = metadata.modified()
        && let Some(dt) = system_time_to_datetime(modified)
    {
        hasher.update(dt.to_rfc3339_opts(SecondsFormat::Micros, true).as_bytes());
    }
    hasher.finalize().into()
}

fn hash_path_digest(path: &Path) -> [u8; 32] {
    let mut hasher = Sha256::new();
    hasher.update(path.to_string_lossy().as_bytes());
    hasher.finalize().into()
}

pub fn hash_path_metadata_full(path: &Path, metadata: &Metadata) -> String {
    let digest = hash_path_metadata_digest(path, metadata);
    let mut out = String::with_capacity(64);
    for b in digest {
        out.push_str(&format!("{:02x}", b));
    }
    out
}

const WORKBOOK_ID_TOKEN_LEN: usize = 10;

fn encode_base32_u64_prefix(value: u64, len: usize) -> String {
    let mut out = String::with_capacity(len);
    for i in 0..len {
        let shift = 64 - (i + 1) * 5;
        let idx = ((value >> shift) & 31) as usize;
        out.push(SHORT_ID_ALPHABET[idx] as char);
    }
    out
}

pub fn hash_path_metadata(path: &Path, metadata: &Metadata) -> String {
    let digest = hash_path_metadata_digest(path, metadata);
    workbook_id_from_digest(digest)
}

pub fn hash_path_identity(path: &Path) -> String {
    let digest = hash_path_digest(path);
    workbook_id_from_digest(digest)
}

fn workbook_id_from_digest(digest: [u8; 32]) -> String {
    let mut bytes = [0u8; 8];
    bytes.copy_from_slice(&digest[..8]);
    let value = u64::from_be_bytes(bytes);

    format!(
        "wb-{}",
        encode_base32_u64_prefix(value, WORKBOOK_ID_TOKEN_LEN)
    )
}

pub fn hash_bytes_sha256_hex(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    format!("{:x}", hasher.finalize())
}

pub fn hash_file_sha256_hex(path: &Path) -> std::io::Result<String> {
    let file = File::open(path)?;
    let mut reader = BufReader::new(file);
    let mut hasher = Sha256::new();
    let mut buffer = [0u8; 64 * 1024];

    loop {
        let read = reader.read(&mut buffer)?;
        if read == 0 {
            break;
        }
        hasher.update(&buffer[..read]);
    }

    Ok(format!("{:x}", hasher.finalize()))
}

pub fn column_number_to_name(column: u32) -> String {
    let mut column = column;
    let mut name = String::new();
    while column > 0 {
        let rem = ((column - 1) % 26) as u8;
        name.insert(0, (b'A' + rem) as char);
        column = (column - 1) / 26;
    }
    name
}

pub fn cell_address(column: u32, row: u32) -> String {
    format!("{}{}", column_number_to_name(column), row)
}

pub fn make_short_workbook_id(_slug: &str, canonical_id: &str) -> String {
    canonical_id
        .strip_prefix("wb-")
        .unwrap_or(canonical_id)
        .to_string()
}

pub fn path_to_forward_slashes(path: &Path) -> String {
    let raw = path.to_string_lossy();
    if raw.contains('\\') {
        raw.replace('\\', "/")
    } else {
        raw.into_owned()
    }
}

const SHORT_ID_ALPHABET: &[u8] = b"23456789abcdefghijkmnpqrstuvwxyz";

pub fn make_short_random_id(prefix: &str, len: usize) -> String {
    let mut rng = rand::thread_rng();

    let mut out = String::with_capacity(prefix.len() + if prefix.is_empty() { 0 } else { 1 } + len);
    if !prefix.is_empty() {
        out.push_str(prefix);
        out.push('-');
    }

    for _ in 0..len {
        let idx = rng.gen_range(0..SHORT_ID_ALPHABET.len());
        out.push(SHORT_ID_ALPHABET[idx] as char);
    }

    out
}