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 super::{ResolvedWorkbookRef, WorkbookRepository, WorkbookSource};
use crate::config::ServerConfig;
#[cfg(feature = "recalc")]
use crate::fork::ForkRegistry;
use crate::model::{WorkbookDescriptor, WorkbookId, WorkbookListResponse};
use crate::tools::filters::WorkbookFilter;
use crate::utils::{
    hash_file_sha256_hex, hash_path_identity, hash_path_metadata, make_short_workbook_id,
    path_to_forward_slashes, system_time_to_rfc3339,
};
use crate::workbook::WorkbookContext;
use anyhow::{Result, anyhow};
use chrono::SecondsFormat;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use walkdir::WalkDir;

pub struct PathWorkspaceRepository {
    config: Arc<ServerConfig>,
    index: RwLock<HashMap<WorkbookId, IndexedWorkbook>>,
    alias_index: RwLock<HashMap<String, WorkbookId>>,
    legacy_alias_index: RwLock<HashMap<String, WorkbookId>>,
    #[cfg(feature = "recalc")]
    fork_registry: Option<Arc<ForkRegistry>>,
}

impl PathWorkspaceRepository {
    #[cfg(feature = "recalc")]
    pub fn new(config: Arc<ServerConfig>, fork_registry: Option<Arc<ForkRegistry>>) -> Self {
        Self {
            config,
            index: RwLock::new(HashMap::new()),
            alias_index: RwLock::new(HashMap::new()),
            legacy_alias_index: RwLock::new(HashMap::new()),
            fork_registry,
        }
    }

    #[cfg(not(feature = "recalc"))]
    pub fn new(config: Arc<ServerConfig>) -> Self {
        Self {
            config,
            index: RwLock::new(HashMap::new()),
            alias_index: RwLock::new(HashMap::new()),
            legacy_alias_index: RwLock::new(HashMap::new()),
        }
    }

    fn register(&self, located: &LocatedWorkbook) {
        self.index.write().insert(
            located.workbook_id.clone(),
            IndexedWorkbook {
                path: located.path.clone(),
                short_id: located.short_id.clone(),
                revision_id: located.revision_id.clone(),
            },
        );

        let mut aliases = self.alias_index.write();
        aliases.insert(
            located.short_id.to_ascii_lowercase(),
            located.workbook_id.clone(),
        );
        aliases.insert(
            located.workbook_id.as_str().to_ascii_lowercase(),
            located.workbook_id.clone(),
        );

        self.legacy_alias_index.write().insert(
            located.legacy_id.to_ascii_lowercase(),
            located.workbook_id.clone(),
        );
    }

    fn register_all(&self, located: &[LocatedWorkbook]) {
        for entry in located {
            self.register(entry);
        }
    }

    fn locate_by_path(&self, path: &Path) -> Result<LocatedWorkbook> {
        let metadata = fs::metadata(path)?;
        let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
        let workbook_id = WorkbookId(hash_path_identity(&canonical));
        let legacy_id = hash_path_metadata(path, &metadata);
        let slug = path
            .file_stem()
            .map(|s| s.to_string_lossy().to_string())
            .unwrap_or_else(|| "workbook".to_string());
        let short_id = make_short_workbook_id(&slug, workbook_id.as_str());

        Ok(LocatedWorkbook {
            workbook_id,
            short_id,
            legacy_id,
            slug,
            folder: derive_folder(&self.config, path),
            path: path.to_path_buf(),
            bytes: metadata.len(),
            last_modified: metadata
                .modified()
                .ok()
                .and_then(system_time_to_rfc3339)
                .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true)),
            revision_id: Some(hash_file_sha256_hex(path)?),
        })
    }

    fn scan_workbooks(&self) -> Result<Vec<LocatedWorkbook>> {
        let mut out = Vec::new();

        if let Some(single) = self.config.single_workbook() {
            out.push(self.locate_by_path(single)?);
            return Ok(out);
        }

        for entry in WalkDir::new(&self.config.workspace_root) {
            let entry = entry?;
            if !entry.file_type().is_file() {
                continue;
            }
            let path = entry.path();
            if !has_supported_extension(&self.config.supported_extensions, path) {
                continue;
            }
            out.push(self.locate_by_path(path)?);
        }

        out.sort_by(|a, b| a.slug.cmp(&b.slug));
        Ok(out)
    }

    fn lookup_indexed(&self, id_or_alias: &WorkbookId) -> Option<WorkbookId> {
        if self.index.read().contains_key(id_or_alias) {
            return Some(id_or_alias.clone());
        }

        let lowered = id_or_alias.as_str().to_ascii_lowercase();
        if let Some(id) = self.alias_index.read().get(&lowered).cloned() {
            return Some(id);
        }
        self.legacy_alias_index.read().get(&lowered).cloned()
    }
}

impl WorkbookRepository for PathWorkspaceRepository {
    fn list(&self, filter: &WorkbookFilter) -> Result<WorkbookListResponse> {
        let located = self.scan_workbooks()?;
        self.register_all(&located);

        let mut descriptors = Vec::new();
        for wb in located {
            if !filter.matches(&wb.slug, wb.folder.as_deref(), &wb.path) {
                continue;
            }

            let relative = wb
                .path
                .strip_prefix(&self.config.workspace_root)
                .unwrap_or(&wb.path);
            descriptors.push(WorkbookDescriptor {
                workbook_id: wb.workbook_id,
                short_id: wb.short_id,
                slug: wb.slug,
                folder: wb.folder,
                path: Some(path_to_forward_slashes(relative)),
                client_path: None,
                bytes: wb.bytes,
                last_modified: wb.last_modified,
                revision_id: wb.revision_id,
                caps: Some(crate::caps::BackendCaps::xlsx()),
            });
        }

        Ok(WorkbookListResponse {
            workbooks: descriptors,
            next_offset: None,
        })
    }

    fn resolve(&self, id_or_alias: &WorkbookId) -> Result<ResolvedWorkbookRef> {
        #[cfg(feature = "recalc")]
        if let Some(registry) = &self.fork_registry
            && let Some(path) = registry.get_fork_path(id_or_alias.as_str())
        {
            return Ok(ResolvedWorkbookRef {
                workbook_id: id_or_alias.clone(),
                short_id: make_short_workbook_id("fork", id_or_alias.as_str()),
                revision_id: Some(hash_file_sha256_hex(&path)?),
                source: WorkbookSource::Path(path),
            });
        }

        if let Some(canonical_id) = self.lookup_indexed(id_or_alias) {
            let indexed = self.index.read().get(&canonical_id).cloned();
            if let Some(indexed) = indexed {
                return Ok(ResolvedWorkbookRef {
                    workbook_id: canonical_id,
                    short_id: indexed.short_id,
                    revision_id: indexed.revision_id,
                    source: WorkbookSource::Path(indexed.path),
                });
            }
        }

        let candidate = id_or_alias.as_str().to_ascii_lowercase();
        let scanned = self.scan_workbooks()?;
        self.register_all(&scanned);

        for wb in scanned {
            if candidate == wb.workbook_id.as_str().to_ascii_lowercase()
                || candidate == wb.short_id.to_ascii_lowercase()
                || candidate == wb.legacy_id.to_ascii_lowercase()
            {
                return Ok(wb.into_resolved());
            }
        }

        Err(anyhow!("workbook id {} not found", id_or_alias.as_str()))
    }

    fn load_context(&self, resolved: &ResolvedWorkbookRef) -> Result<WorkbookContext> {
        match &resolved.source {
            WorkbookSource::Path(path) => WorkbookContext::load_from_path(
                &self.config,
                path,
                resolved.workbook_id.clone(),
                resolved.short_id.clone(),
                resolved.revision_id.clone(),
            ),
            WorkbookSource::Virtual(id) => Err(anyhow!(
                "path workspace repository cannot load virtual workbook {id}"
            )),
        }
    }
}

struct LocatedWorkbook {
    workbook_id: WorkbookId,
    short_id: String,
    legacy_id: String,
    slug: String,
    folder: Option<String>,
    path: PathBuf,
    bytes: u64,
    last_modified: Option<String>,
    revision_id: Option<String>,
}

impl LocatedWorkbook {
    fn into_resolved(self) -> ResolvedWorkbookRef {
        ResolvedWorkbookRef {
            workbook_id: self.workbook_id,
            short_id: self.short_id,
            revision_id: self.revision_id,
            source: WorkbookSource::Path(self.path),
        }
    }
}

#[derive(Clone)]
struct IndexedWorkbook {
    path: PathBuf,
    short_id: String,
    revision_id: Option<String>,
}

fn derive_folder(config: &Arc<ServerConfig>, path: &Path) -> Option<String> {
    path.strip_prefix(&config.workspace_root)
        .ok()
        .and_then(|relative| relative.parent())
        .and_then(|parent| parent.file_name())
        .map(|os| os.to_string_lossy().to_string())
}

fn has_supported_extension(allowed: &[String], path: &Path) -> bool {
    path.extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| {
            let lower = ext.to_ascii_lowercase();
            allowed.iter().any(|candidate| candidate == &lower)
        })
        .unwrap_or(false)
}