use crate::config::ServerConfig;
use crate::errors::InvalidParamsError;
use crate::model::{
CloseWorkbookResponse, FindFormulaResponse, FindValueResponse, FormulaTraceResponse,
ManifestStubResponse, NamedRangesResponse, RangeValuesResponse, ReadTableResponse,
SheetFormulaMapResponse, SheetListResponse, SheetOverviewResponse, SheetPageResponse,
SheetStatisticsResponse, SheetStylesResponse, TableProfileResponse, VolatileScanResponse,
WorkbookDescription, WorkbookListResponse, WorkbookStyleSummaryResponse,
WorkbookSummaryResponse,
};
use crate::response_prune::Pruned;
#[cfg(feature = "recalc")]
use crate::response_prune::to_pruned_value;
use crate::state::AppState;
use crate::tools;
use anyhow::{Result, anyhow};
use rmcp::{
ErrorData as McpError, Json as McpJson, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{Implementation, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
};
use serde::Serialize;
use std::future::Future;
use std::sync::Arc;
use thiserror::Error;
use {once_cell::sync::Lazy, regex::Regex};
type Json<T> = McpJson<Pruned<T>>;
fn json<T>(value: T) -> Json<T> {
McpJson(Pruned(value))
}
const BASE_INSTRUCTIONS: &str = "\
Spreadsheet MCP: optimized for spreadsheet analysis.
WORKFLOW:
1) list_workbooks → list_sheets → workbook_summary for orientation
2) sheet_overview for region detection (ids/bounds/kind/confidence)
3) For structured data: table_profile for quick column sense, then read_table with region_id/range, filters, sampling
4) For spot checks: range_values or find_value (label mode for key-value sheets)
TOOL SELECTION:
- table_profile: Fast column/type summary before wide reads.
- read_table: Structured table extraction. Prefer region_id or tight range; use limit + sample_mode.
- sheet_formula_map: Get formula overview. Use limit param for large sheets (e.g., limit=10). \
Use sort_by='complexity' for most complex formulas first, or 'count' for most repeated. \
Use range param to scope to specific region.
- formula_trace: Trace ONE cell's precedents/dependents. Use AFTER formula_map \
to dive deep on specific outputs (e.g., trace the total cell to understand calc flow).
- sheet_page: Raw cell dump. Use ONLY when region detection fails or for \
unstructured sheets. Prefer read_table for tabular data.
- find_value with mode='label': For key-value layouts (label in col A, value in col B). \
Use direction='right' or 'below' hints.
- find_formula: Search formulas. Default returns no context and only first 50 matches. \
Use include_context=true for header+cell snapshots, and use limit/offset to page.
OUTPUT DEFAULTS (token-dense profile):
- read_table defaults to format=csv (flat string). Use format=values for raw arrays, or format=json for typed cells.
- range_values defaults to format=values. Use format=csv or format=json as needed.
- sheet_page defaults to format=compact; set format=full for per-cell objects.
- table_profile defaults to summary_only=true (no samples). Set summary_only=false to include sample rows.
- sheet_statistics defaults to summary_only=true (no samples). Set summary_only=false to include samples.
- sheet_styles defaults to summary_only=true (no descriptors/ranges/examples). Use include_descriptor/include_ranges/include_example_cells.
- workbook_style_summary defaults to summary_only=true (no theme/conditional formats/descriptors). Use include_theme/include_conditional_formats/include_descriptor/include_example_cells.
- sheet_formula_map defaults to summary_only=true (addresses hidden). Set include_addresses=true to show cell addresses.
- find_value defaults to context=none (no neighbors/row_context). Use context=neighbors, context=row, or context=both.
- scan_volatiles defaults to summary_only=true (addresses hidden). Set include_addresses=true to list addresses.
- list_workbooks defaults to include_paths=false (no paths/caps). Set include_paths=true to show them.
- list_sheets defaults to include_bounds=false (no row/column counts). Set include_bounds=true to show them.
- workbook_summary defaults to summary_only=true (no entry points/named ranges). Set summary_only=false or include_entry_points/include_named_ranges.
- Pagination fields (next_offset/next_start_row) only appear when more data exists.
RANGES: Use A1 notation (e.g., A1:C10). Prefer region_id when available.
DATES: Cells with date formats return ISO-8601 strings (YYYY-MM-DD).
Keep payloads small. Page through large sheets.";
const VBA_INSTRUCTIONS: &str = "
VBA TOOLS (enabled):
Read-only VBA project inspection for .xlsm workbooks.
WORKFLOW:
1) list_workbooks → describe_workbook to find candidate .xlsm
2) vba_project_summary to list modules
3) vba_module_source to page module code
TOOLS:
- vba_project_summary: Parse and summarize the embedded vbaProject.bin (modules + metadata).
- vba_module_source: Return paged source for one module (use offset_lines/limit_lines).
SAFETY:
- Treat VBA as untrusted code. Tools only read and return text.
- Responses are size-limited; page through module source.
";
const WRITE_INSTRUCTIONS: &str = "
WRITE/RECALC TOOLS (enabled):
Fork-based editing allows 'what-if' analysis without modifying original files.
WORKFLOW:
1) create_fork: Create editable copy of a workbook. Returns fork_id.
2) Optional: checkpoint_fork before large edits.
3) edit_batch/transform_batch/style_batch/structure_batch/apply_formula_pattern/sheet_layout_batch/rules_batch/column_size_batch: Apply edits to the fork.
4) recalculate: Trigger the configured recalc backend to recompute all formulas.
5) get_changeset: Diff fork against original. Use filters/limit/offset to keep it small.
Optional: screenshot_sheet to capture a visual view of a range (original or fork).
6) save_fork: Write changes to file.
7) discard_fork: Delete fork without saving.
SAFETY:
- checkpoint_fork before large/structural edits; restore_checkpoint to rollback if needed.
- Tools with mode='preview' create staged changes (transform_batch/style_batch/structure_batch/apply_formula_pattern); use list_staged_changes + apply_staged_change/discard_staged_change.
TOOL DETAILS:
- create_fork: Only .xlsx supported. Returns fork_id for subsequent operations.
- edit_batch: {fork_id, sheet_name, edits:[{address, value, is_formula} | `A1=100`]}. \
Shorthand edits like `A1=100` or `B2==SUM(A1:A2)` are accepted. \
Leading '=' in value/formula is accepted and stripped; prefer formula or is_formula=true for clarity.
- transform_batch: Range-first clear/fill/replace. Prefer for bulk edits (blank/fill/rename) to avoid per-cell edit_batch bloat.
- recalculate: Required after edit_batch to update formula results. \
May take several seconds for complex workbooks.
- get_changeset: Returns a paged diff + summary. Use limit/offset to page. \
Use include_types/exclude_types/include_subtypes/exclude_subtypes to filter (e.g. exclude_subtypes=['recalc_result']). \
Use summary_only=true when you only need counts.
- screenshot_sheet: {workbook_or_fork_id, sheet_name, range?}. Renders a cropped PNG for inspecting an area visually.
workbook_or_fork_id may be either a real workbook_id OR a fork_id (to screenshot an edited fork).
Returns a file:// URI under screenshot_dir (default: <workspace_root>/screenshots).
If path mapping is configured (--path-map), client_output_path is included to help locate the file on the host.
DO NOT call save_fork just to get a screenshot.
If formulas changed, run recalculate on the fork first.
- save_fork: Requires target_path for new file location.
If target_path is relative, it is resolved under workspace_root (Docker default: `/data`).
If target_path is absolute and matches a configured path mapping, it is mapped to the internal path automatically.
If path mapping is configured (--path-map), client_saved_to is included.
Overwriting original requires server --allow-overwrite flag.
Use drop_fork=false to keep fork active after saving (default: true drops fork).
Validates base file unchanged since fork creation.
- get_edits: List all edits applied to a fork (before recalculate).
- list_forks: See all active forks.
- checkpoint_fork: Snapshot a fork to a checkpoint for high-fidelity undo.
- list_checkpoints: List checkpoints for a fork.
- restore_checkpoint: Restore a fork to a checkpoint (overwrites fork file; clears newer staged changes).
- delete_checkpoint: Delete a checkpoint.
- list_staged_changes: List staged (previewed) changes for a fork.
- apply_staged_change: Apply a staged change to the fork.
- discard_staged_change: Discard a staged change.
BEST PRACTICES:
- Always recalculate after edit_batch before get_changeset.
- Review changeset before save_fork to verify expected changes.
- Use screenshot_sheet for quick visual inspection; save_fork is ONLY for exporting a workbook file.
- Discard forks when done to free resources (fork TTL is disabled by default).
- For large edits, batch multiple cells in single edit_batch call.";
fn build_instructions(recalc_enabled: bool, vba_enabled: bool) -> String {
let mut instructions = BASE_INSTRUCTIONS.to_string();
if vba_enabled {
instructions.push_str(VBA_INSTRUCTIONS);
} else {
instructions
.push_str("\n\nVBA tools disabled. Set SPREADSHEET_MCP_VBA_ENABLED=true to enable.");
}
if recalc_enabled {
instructions.push_str(WRITE_INSTRUCTIONS);
} else {
instructions.push_str("\n\nRead-only mode. Write/recalc tools disabled.");
}
instructions
}
#[derive(Clone)]
pub struct SpreadsheetServer {
state: Arc<AppState>,
tool_router: ToolRouter<SpreadsheetServer>,
}
impl SpreadsheetServer {
pub async fn new(config: Arc<ServerConfig>) -> Result<Self> {
config.ensure_workspace_root()?;
let state = Arc::new(AppState::new(config));
Ok(Self::from_state(state))
}
pub fn from_state(state: Arc<AppState>) -> Self {
#[allow(unused_mut)]
let mut router = Self::tool_router();
#[cfg(feature = "recalc")]
{
router.merge(Self::fork_tool_router());
}
if state.config().vba_enabled {
router.merge(Self::vba_tool_router());
}
Self {
state,
tool_router: router,
}
}
pub async fn run_stdio(self) -> Result<()> {
let service = self
.serve(stdio())
.await
.inspect_err(|error| tracing::error!("serving error: {:?}", error))?;
service.waiting().await?;
Ok(())
}
pub async fn run(self) -> Result<()> {
self.run_stdio().await
}
fn ensure_tool_enabled(&self, tool: &str) -> Result<()> {
tracing::info!(tool = tool, "tool invocation requested");
if self.state.config().is_tool_enabled(tool) {
Ok(())
} else {
Err(ToolDisabledError::new(tool).into())
}
}
fn ensure_vba_enabled(&self, tool: &str) -> Result<()> {
self.ensure_tool_enabled(tool)?;
if self.state.config().vba_enabled {
Ok(())
} else {
Err(VbaDisabledError.into())
}
}
#[cfg(feature = "recalc")]
fn ensure_recalc_enabled(&self, tool: &str) -> Result<()> {
self.ensure_tool_enabled(tool)?;
if self.state.config().recalc_enabled {
Ok(())
} else {
Err(RecalcDisabledError.into())
}
}
async fn run_tool_with_timeout<T, F>(&self, tool: &str, fut: F) -> Result<T>
where
F: Future<Output = Result<T>>,
T: Serialize,
{
let result = if let Some(timeout_duration) = self.state.config().tool_timeout() {
match tokio::time::timeout(timeout_duration, fut).await {
Ok(result) => result,
Err(_) => Err(anyhow!(
"tool '{}' timed out after {}ms",
tool,
timeout_duration.as_millis()
)),
}
} else {
fut.await
}?;
self.ensure_response_size(tool, &result)?;
Ok(result)
}
fn ensure_response_size<T: Serialize>(&self, tool: &str, value: &T) -> Result<()> {
let Some(limit) = self.state.config().max_response_bytes() else {
return Ok(());
};
let payload = serde_json::to_vec(value)
.map_err(|e| anyhow!("failed to serialize response for {}: {}", tool, e))?;
if payload.len() > limit {
return Err(ResponseTooLargeError::new(tool, payload.len(), limit).into());
}
Ok(())
}
}
#[tool_router]
impl SpreadsheetServer {
#[tool(
name = "list_workbooks",
description = "List spreadsheet files in the workspace"
)]
pub async fn list_workbooks(
&self,
Parameters(params): Parameters<tools::ListWorkbooksParams>,
) -> Result<Json<WorkbookListResponse>, McpError> {
self.ensure_tool_enabled("list_workbooks")
.map_err(|e| to_mcp_error_for_tool("list_workbooks", e))?;
self.run_tool_with_timeout(
"list_workbooks",
tools::list_workbooks(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("list_workbooks", e))
}
#[tool(name = "describe_workbook", description = "Describe workbook metadata")]
pub async fn describe_workbook(
&self,
Parameters(params): Parameters<tools::DescribeWorkbookParams>,
) -> Result<Json<WorkbookDescription>, McpError> {
self.ensure_tool_enabled("describe_workbook")
.map_err(|e| to_mcp_error_for_tool("describe_workbook", e))?;
self.run_tool_with_timeout(
"describe_workbook",
tools::describe_workbook(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("describe_workbook", e))
}
#[tool(
name = "workbook_summary",
description = "Summarize workbook regions and entry points"
)]
pub async fn workbook_summary(
&self,
Parameters(params): Parameters<tools::WorkbookSummaryParams>,
) -> Result<Json<WorkbookSummaryResponse>, McpError> {
self.ensure_tool_enabled("workbook_summary")
.map_err(|e| to_mcp_error_for_tool("workbook_summary", e))?;
self.run_tool_with_timeout(
"workbook_summary",
tools::workbook_summary(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("workbook_summary", e))
}
#[tool(name = "list_sheets", description = "List sheets with summaries")]
pub async fn list_sheets(
&self,
Parameters(params): Parameters<tools::ListSheetsParams>,
) -> Result<Json<SheetListResponse>, McpError> {
self.ensure_tool_enabled("list_sheets")
.map_err(|e| to_mcp_error_for_tool("list_sheets", e))?;
self.run_tool_with_timeout(
"list_sheets",
tools::list_sheets(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("list_sheets", e))
}
#[tool(
name = "sheet_overview",
description = "Get narrative overview for a sheet"
)]
pub async fn sheet_overview(
&self,
Parameters(params): Parameters<tools::SheetOverviewParams>,
) -> Result<Json<SheetOverviewResponse>, McpError> {
self.ensure_tool_enabled("sheet_overview")
.map_err(|e| to_mcp_error_for_tool("sheet_overview", e))?;
self.run_tool_with_timeout(
"sheet_overview",
tools::sheet_overview(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("sheet_overview", e))
}
#[tool(name = "sheet_page", description = "Page through sheet cells")]
pub async fn sheet_page(
&self,
Parameters(params): Parameters<tools::SheetPageParams>,
) -> Result<Json<SheetPageResponse>, McpError> {
self.ensure_tool_enabled("sheet_page")
.map_err(|e| to_mcp_error_for_tool("sheet_page", e))?;
self.run_tool_with_timeout("sheet_page", tools::sheet_page(self.state.clone(), params))
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("sheet_page", e))
}
#[tool(name = "find_value", description = "Search cell values or labels")]
pub async fn find_value(
&self,
Parameters(params): Parameters<tools::FindValueParams>,
) -> Result<Json<FindValueResponse>, McpError> {
self.ensure_tool_enabled("find_value")
.map_err(|e| to_mcp_error_for_tool("find_value", e))?;
self.run_tool_with_timeout("find_value", tools::find_value(self.state.clone(), params))
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("find_value", e))
}
#[tool(
name = "read_table",
description = "Read structured data from a range or table"
)]
pub async fn read_table(
&self,
Parameters(params): Parameters<tools::ReadTableParams>,
) -> Result<Json<ReadTableResponse>, McpError> {
self.ensure_tool_enabled("read_table")
.map_err(|e| to_mcp_error_for_tool("read_table", e))?;
self.run_tool_with_timeout("read_table", tools::read_table(self.state.clone(), params))
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("read_table", e))
}
#[tool(name = "table_profile", description = "Profile a region or table")]
pub async fn table_profile(
&self,
Parameters(params): Parameters<tools::TableProfileParams>,
) -> Result<Json<TableProfileResponse>, McpError> {
self.ensure_tool_enabled("table_profile")
.map_err(|e| to_mcp_error_for_tool("table_profile", e))?;
self.run_tool_with_timeout(
"table_profile",
tools::table_profile(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("table_profile", e))
}
#[tool(
name = "range_values",
description = "Fetch raw values for specific ranges"
)]
pub async fn range_values(
&self,
Parameters(params): Parameters<tools::RangeValuesParams>,
) -> Result<Json<RangeValuesResponse>, McpError> {
self.ensure_tool_enabled("range_values")
.map_err(|e| to_mcp_error_for_tool("range_values", e))?;
self.run_tool_with_timeout(
"range_values",
tools::range_values(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("range_values", e))
}
#[tool(
name = "sheet_statistics",
description = "Get aggregated sheet statistics"
)]
pub async fn sheet_statistics(
&self,
Parameters(params): Parameters<tools::SheetStatisticsParams>,
) -> Result<Json<SheetStatisticsResponse>, McpError> {
self.ensure_tool_enabled("sheet_statistics")
.map_err(|e| to_mcp_error_for_tool("sheet_statistics", e))?;
self.run_tool_with_timeout(
"sheet_statistics",
tools::sheet_statistics(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("sheet_statistics", e))
}
#[tool(
name = "sheet_formula_map",
description = "Summarize formula groups across a sheet"
)]
pub async fn sheet_formula_map(
&self,
Parameters(params): Parameters<tools::SheetFormulaMapParams>,
) -> Result<Json<SheetFormulaMapResponse>, McpError> {
self.ensure_tool_enabled("sheet_formula_map")
.map_err(|e| to_mcp_error_for_tool("sheet_formula_map", e))?;
self.run_tool_with_timeout(
"sheet_formula_map",
tools::sheet_formula_map(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("sheet_formula_map", e))
}
#[tool(
name = "formula_trace",
description = "Trace formula precedents or dependents"
)]
pub async fn formula_trace(
&self,
Parameters(params): Parameters<tools::FormulaTraceParams>,
) -> Result<Json<FormulaTraceResponse>, McpError> {
self.ensure_tool_enabled("formula_trace")
.map_err(|e| to_mcp_error_for_tool("formula_trace", e))?;
self.run_tool_with_timeout(
"formula_trace",
tools::formula_trace(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("formula_trace", e))
}
#[tool(name = "named_ranges", description = "List named ranges and tables")]
pub async fn named_ranges(
&self,
Parameters(params): Parameters<tools::NamedRangesParams>,
) -> Result<Json<NamedRangesResponse>, McpError> {
self.ensure_tool_enabled("named_ranges")
.map_err(|e| to_mcp_error_for_tool("named_ranges", e))?;
self.run_tool_with_timeout(
"named_ranges",
tools::named_ranges(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("named_ranges", e))
}
#[tool(
name = "find_formula",
description = "Search formulas containing text. Defaults: include_context=false, limit=50; use offset for paging."
)]
pub async fn find_formula(
&self,
Parameters(params): Parameters<tools::FindFormulaParams>,
) -> Result<Json<FindFormulaResponse>, McpError> {
self.ensure_tool_enabled("find_formula")
.map_err(|e| to_mcp_error_for_tool("find_formula", e))?;
self.run_tool_with_timeout(
"find_formula",
tools::find_formula(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("find_formula", e))
}
#[tool(name = "scan_volatiles", description = "Scan for volatile formulas")]
pub async fn scan_volatiles(
&self,
Parameters(params): Parameters<tools::ScanVolatilesParams>,
) -> Result<Json<VolatileScanResponse>, McpError> {
self.ensure_tool_enabled("scan_volatiles")
.map_err(|e| to_mcp_error_for_tool("scan_volatiles", e))?;
self.run_tool_with_timeout(
"scan_volatiles",
tools::scan_volatiles(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("scan_volatiles", e))
}
#[tool(
name = "sheet_styles",
description = "Summarise style usage and properties for a sheet"
)]
pub async fn sheet_styles(
&self,
Parameters(params): Parameters<tools::SheetStylesParams>,
) -> Result<Json<SheetStylesResponse>, McpError> {
self.ensure_tool_enabled("sheet_styles")
.map_err(|e| to_mcp_error_for_tool("sheet_styles", e))?;
self.run_tool_with_timeout(
"sheet_styles",
tools::sheet_styles(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("sheet_styles", e))
}
#[tool(
name = "workbook_style_summary",
description = "Summarise style usage, theme colors, and conditional formats across a workbook"
)]
pub async fn workbook_style_summary(
&self,
Parameters(params): Parameters<tools::WorkbookStyleSummaryParams>,
) -> Result<Json<WorkbookStyleSummaryResponse>, McpError> {
self.ensure_tool_enabled("workbook_style_summary")
.map_err(|e| to_mcp_error_for_tool("workbook_style_summary", e))?;
self.run_tool_with_timeout(
"workbook_style_summary",
tools::workbook_style_summary(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("workbook_style_summary", e))
}
#[tool(
name = "get_manifest_stub",
description = "Generate manifest scaffold for workbook"
)]
pub async fn get_manifest_stub(
&self,
Parameters(params): Parameters<tools::ManifestStubParams>,
) -> Result<Json<ManifestStubResponse>, McpError> {
self.ensure_tool_enabled("get_manifest_stub")
.map_err(|e| to_mcp_error_for_tool("get_manifest_stub", e))?;
self.run_tool_with_timeout(
"get_manifest_stub",
tools::get_manifest_stub(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("get_manifest_stub", e))
}
#[tool(name = "close_workbook", description = "Evict a workbook from cache")]
pub async fn close_workbook(
&self,
Parameters(params): Parameters<tools::CloseWorkbookParams>,
) -> Result<Json<CloseWorkbookResponse>, McpError> {
self.ensure_tool_enabled("close_workbook")
.map_err(|e| to_mcp_error_for_tool("close_workbook", e))?;
self.run_tool_with_timeout(
"close_workbook",
tools::close_workbook(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("close_workbook", e))
}
}
#[tool_router(router = vba_tool_router)]
impl SpreadsheetServer {
#[tool(
name = "vba_project_summary",
description = "Summarize embedded VBA project (xlsm)"
)]
pub async fn vba_project_summary(
&self,
Parameters(params): Parameters<tools::vba::VbaProjectSummaryParams>,
) -> Result<Json<crate::model::VbaProjectSummaryResponse>, McpError> {
self.ensure_vba_enabled("vba_project_summary")
.map_err(|e| to_mcp_error_for_tool("vba_project_summary", e))?;
self.run_tool_with_timeout(
"vba_project_summary",
tools::vba::vba_project_summary(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("vba_project_summary", e))
}
#[tool(
name = "vba_module_source",
description = "Read VBA module source (paged)"
)]
pub async fn vba_module_source(
&self,
Parameters(params): Parameters<tools::vba::VbaModuleSourceParams>,
) -> Result<Json<crate::model::VbaModuleSourceResponse>, McpError> {
self.ensure_vba_enabled("vba_module_source")
.map_err(|e| to_mcp_error_for_tool("vba_module_source", e))?;
self.run_tool_with_timeout(
"vba_module_source",
tools::vba::vba_module_source(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("vba_module_source", e))
}
}
#[cfg(feature = "recalc")]
#[tool_router(router = fork_tool_router)]
impl SpreadsheetServer {
#[tool(
name = "create_fork",
description = "Create a temporary editable copy of a workbook for what-if analysis"
)]
pub async fn create_fork(
&self,
Parameters(params): Parameters<tools::fork::CreateForkParams>,
) -> Result<Json<tools::fork::CreateForkResponse>, McpError> {
self.ensure_recalc_enabled("create_fork")
.map_err(|e| to_mcp_error_for_tool("create_fork", e))?;
self.run_tool_with_timeout(
"create_fork",
tools::fork::create_fork(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("create_fork", e))
}
#[tool(
name = "edit_batch",
description = "Apply batch edits (values or formulas) to a fork"
)]
pub async fn edit_batch(
&self,
Parameters(params): Parameters<tools::write_normalize::EditBatchParamsInput>,
) -> Result<Json<tools::fork::EditBatchResponse>, McpError> {
self.ensure_recalc_enabled("edit_batch")
.map_err(|e| to_mcp_error_for_tool("edit_batch", e))?;
self.run_tool_with_timeout(
"edit_batch",
tools::fork::edit_batch(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("edit_batch", e))
}
#[tool(
name = "transform_batch",
description = "Range-oriented transforms for a fork (clear/fill/replace). Supports targets by range, region_id, or explicit cells. \
Mode: preview or apply (default apply)."
)]
pub async fn transform_batch(
&self,
Parameters(params): Parameters<tools::fork::TransformBatchParams>,
) -> Result<Json<tools::fork::TransformBatchResponse>, McpError> {
self.ensure_recalc_enabled("transform_batch")
.map_err(|e| to_mcp_error_for_tool("transform_batch", e))?;
self.run_tool_with_timeout(
"transform_batch",
tools::fork::transform_batch(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("transform_batch", e))
}
#[tool(
name = "style_batch",
description = "Apply batch style edits to a fork. Supports targets by range, region_id, or explicit cells. \
Mode: preview or apply (default apply). Op mode: merge (default), set, or clear."
)]
pub async fn style_batch(
&self,
Parameters(params): Parameters<tools::fork::StyleBatchParamsInput>,
) -> Result<Json<tools::fork::StyleBatchResponse>, McpError> {
self.ensure_recalc_enabled("style_batch")
.map_err(|e| to_mcp_error_for_tool("style_batch", e))?;
self.run_tool_with_timeout(
"style_batch",
tools::fork::style_batch(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("style_batch", e))
}
#[tool(
name = "column_size_batch",
description = "Set column widths or compute auto-widths in a fork. Targets column ranges like 'A:A' or 'A:C'. \
Mode: preview or apply (default apply). Auto computes and sets widths immediately (persisted). \
Note: autosize uses cached/formatted cell values; if a column is mostly formulas with no cached results, widths may be too narrow unless you recalculate first."
)]
pub async fn column_size_batch(
&self,
Parameters(params): Parameters<tools::fork::ColumnSizeBatchParamsInput>,
) -> Result<Json<tools::fork::ColumnSizeBatchResponse>, McpError> {
self.ensure_recalc_enabled("column_size_batch")
.map_err(|e| to_mcp_error_for_tool("column_size_batch", e))?;
self.run_tool_with_timeout(
"column_size_batch",
tools::fork::column_size_batch(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("column_size_batch", e))
}
#[tool(
name = "sheet_layout_batch",
description = "Apply sheet layout/view/print settings in a fork (freeze panes, zoom, gridlines, margins, setup, print area, page breaks). Mode: preview or apply (default apply)."
)]
pub async fn sheet_layout_batch(
&self,
Parameters(params): Parameters<tools::sheet_layout::SheetLayoutBatchParams>,
) -> Result<Json<tools::sheet_layout::SheetLayoutBatchResponse>, McpError> {
self.ensure_recalc_enabled("sheet_layout_batch")
.map_err(|e| to_mcp_error_for_tool("sheet_layout_batch", e))?;
self.run_tool_with_timeout(
"sheet_layout_batch",
tools::sheet_layout::sheet_layout_batch(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("sheet_layout_batch", e))
}
#[tool(
name = "apply_formula_pattern",
description = "Autofill-like formula pattern application over a target range in a fork. \
Provide base_formula at anchor_cell, then fill across target_range. \
Mode: preview or apply (default apply). relative_mode: excel (default), abs_cols, abs_rows. \
fill_direction: down, right, both (default both)."
)]
pub async fn apply_formula_pattern(
&self,
Parameters(params): Parameters<tools::fork::ApplyFormulaPatternParams>,
) -> Result<Json<tools::fork::ApplyFormulaPatternResponse>, McpError> {
self.ensure_recalc_enabled("apply_formula_pattern")
.map_err(|e| to_mcp_error_for_tool("apply_formula_pattern", e))?;
self.run_tool_with_timeout(
"apply_formula_pattern",
tools::fork::apply_formula_pattern(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("apply_formula_pattern", e))
}
#[tool(
name = "structure_batch",
description = "Apply structural edits to a fork (rows/cols/sheets). \
Mode: preview or apply (default apply). Aliases: op for kind, add_sheet for create_sheet. \
Note: structural edits may not fully rewrite formulas/named ranges like Excel; run recalculate and review get_changeset after applying."
)]
pub async fn structure_batch(
&self,
Parameters(params): Parameters<tools::fork::StructureBatchParamsInput>,
) -> Result<Json<tools::fork::StructureBatchResponse>, McpError> {
self.ensure_recalc_enabled("structure_batch")
.map_err(|e| to_mcp_error_for_tool("structure_batch", e))?;
self.run_tool_with_timeout(
"structure_batch",
tools::fork::structure_batch(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("structure_batch", e))
}
#[tool(
name = "rules_batch",
description = "Apply rule operations to a fork (DV v1: set_data_validation; CF v1: add/set/clear conditional formats). Mode: preview or apply (default apply)."
)]
pub async fn rules_batch(
&self,
Parameters(params): Parameters<tools::rules_batch::RulesBatchParams>,
) -> Result<Json<tools::rules_batch::RulesBatchResponse>, McpError> {
self.ensure_recalc_enabled("rules_batch")
.map_err(|e| to_mcp_error_for_tool("rules_batch", e))?;
self.run_tool_with_timeout(
"rules_batch",
tools::rules_batch::rules_batch(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("rules_batch", e))
}
#[tool(name = "get_edits", description = "List all edits applied to a fork")]
pub async fn get_edits(
&self,
Parameters(params): Parameters<tools::fork::GetEditsParams>,
) -> Result<Json<tools::fork::GetEditsResponse>, McpError> {
self.ensure_recalc_enabled("get_edits")
.map_err(|e| to_mcp_error_for_tool("get_edits", e))?;
self.run_tool_with_timeout(
"get_edits",
tools::fork::get_edits(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("get_edits", e))
}
#[tool(
name = "get_changeset",
description = "Calculate diff between fork and base workbook. Defaults: limit=200. Supports limit/offset paging and type/subtype filters; returns summary."
)]
pub async fn get_changeset(
&self,
Parameters(params): Parameters<tools::fork::GetChangesetParams>,
) -> Result<Json<tools::fork::GetChangesetResponse>, McpError> {
self.ensure_recalc_enabled("get_changeset")
.map_err(|e| to_mcp_error_for_tool("get_changeset", e))?;
self.run_tool_with_timeout(
"get_changeset",
tools::fork::get_changeset(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("get_changeset", e))
}
#[tool(
name = "recalculate",
description = "Recalculate all formulas in a fork"
)]
pub async fn recalculate(
&self,
Parameters(params): Parameters<tools::fork::RecalculateParams>,
) -> Result<Json<tools::fork::RecalculateResponse>, McpError> {
self.ensure_recalc_enabled("recalculate")
.map_err(|e| to_mcp_error_for_tool("recalculate", e))?;
self.run_tool_with_timeout(
"recalculate",
tools::fork::recalculate(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("recalculate", e))
}
#[tool(name = "list_forks", description = "List all active forks")]
pub async fn list_forks(
&self,
Parameters(params): Parameters<tools::fork::ListForksParams>,
) -> Result<Json<tools::fork::ListForksResponse>, McpError> {
self.ensure_recalc_enabled("list_forks")
.map_err(|e| to_mcp_error_for_tool("list_forks", e))?;
self.run_tool_with_timeout(
"list_forks",
tools::fork::list_forks(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("list_forks", e))
}
#[tool(name = "discard_fork", description = "Discard a fork without saving")]
pub async fn discard_fork(
&self,
Parameters(params): Parameters<tools::fork::DiscardForkParams>,
) -> Result<Json<tools::fork::DiscardForkResponse>, McpError> {
self.ensure_recalc_enabled("discard_fork")
.map_err(|e| to_mcp_error_for_tool("discard_fork", e))?;
self.run_tool_with_timeout(
"discard_fork",
tools::fork::discard_fork(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("discard_fork", e))
}
#[tool(
name = "save_fork",
description = "Save fork changes to target path (defaults to overwriting original)"
)]
pub async fn save_fork(
&self,
Parameters(params): Parameters<tools::fork::SaveForkParams>,
) -> Result<Json<tools::fork::SaveForkResponse>, McpError> {
self.ensure_recalc_enabled("save_fork")
.map_err(|e| to_mcp_error_for_tool("save_fork", e))?;
self.run_tool_with_timeout(
"save_fork",
tools::fork::save_fork(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("save_fork", e))
}
#[tool(
name = "checkpoint_fork",
description = "Create a high-fidelity checkpoint snapshot of a fork"
)]
pub async fn checkpoint_fork(
&self,
Parameters(params): Parameters<tools::fork::CheckpointForkParams>,
) -> Result<Json<tools::fork::CheckpointForkResponse>, McpError> {
self.ensure_recalc_enabled("checkpoint_fork")
.map_err(|e| to_mcp_error_for_tool("checkpoint_fork", e))?;
self.run_tool_with_timeout(
"checkpoint_fork",
tools::fork::checkpoint_fork(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("checkpoint_fork", e))
}
#[tool(name = "list_checkpoints", description = "List checkpoints for a fork")]
pub async fn list_checkpoints(
&self,
Parameters(params): Parameters<tools::fork::ListCheckpointsParams>,
) -> Result<Json<tools::fork::ListCheckpointsResponse>, McpError> {
self.ensure_recalc_enabled("list_checkpoints")
.map_err(|e| to_mcp_error_for_tool("list_checkpoints", e))?;
self.run_tool_with_timeout(
"list_checkpoints",
tools::fork::list_checkpoints(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("list_checkpoints", e))
}
#[tool(
name = "restore_checkpoint",
description = "Restore a fork to a checkpoint"
)]
pub async fn restore_checkpoint(
&self,
Parameters(params): Parameters<tools::fork::RestoreCheckpointParams>,
) -> Result<Json<tools::fork::RestoreCheckpointResponse>, McpError> {
self.ensure_recalc_enabled("restore_checkpoint")
.map_err(|e| to_mcp_error_for_tool("restore_checkpoint", e))?;
self.run_tool_with_timeout(
"restore_checkpoint",
tools::fork::restore_checkpoint(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("restore_checkpoint", e))
}
#[tool(
name = "delete_checkpoint",
description = "Delete a checkpoint from a fork"
)]
pub async fn delete_checkpoint(
&self,
Parameters(params): Parameters<tools::fork::DeleteCheckpointParams>,
) -> Result<Json<tools::fork::DeleteCheckpointResponse>, McpError> {
self.ensure_recalc_enabled("delete_checkpoint")
.map_err(|e| to_mcp_error_for_tool("delete_checkpoint", e))?;
self.run_tool_with_timeout(
"delete_checkpoint",
tools::fork::delete_checkpoint(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("delete_checkpoint", e))
}
#[tool(
name = "list_staged_changes",
description = "List previewed/staged changes for a fork"
)]
pub async fn list_staged_changes(
&self,
Parameters(params): Parameters<tools::fork::ListStagedChangesParams>,
) -> Result<Json<tools::fork::ListStagedChangesResponse>, McpError> {
self.ensure_recalc_enabled("list_staged_changes")
.map_err(|e| to_mcp_error_for_tool("list_staged_changes", e))?;
self.run_tool_with_timeout(
"list_staged_changes",
tools::fork::list_staged_changes(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("list_staged_changes", e))
}
#[tool(
name = "apply_staged_change",
description = "Apply a staged change to a fork"
)]
pub async fn apply_staged_change(
&self,
Parameters(params): Parameters<tools::fork::ApplyStagedChangeParams>,
) -> Result<Json<tools::fork::ApplyStagedChangeResponse>, McpError> {
self.ensure_recalc_enabled("apply_staged_change")
.map_err(|e| to_mcp_error_for_tool("apply_staged_change", e))?;
self.run_tool_with_timeout(
"apply_staged_change",
tools::fork::apply_staged_change(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("apply_staged_change", e))
}
#[tool(
name = "discard_staged_change",
description = "Discard a staged change without applying it"
)]
pub async fn discard_staged_change(
&self,
Parameters(params): Parameters<tools::fork::DiscardStagedChangeParams>,
) -> Result<Json<tools::fork::DiscardStagedChangeResponse>, McpError> {
self.ensure_recalc_enabled("discard_staged_change")
.map_err(|e| to_mcp_error_for_tool("discard_staged_change", e))?;
self.run_tool_with_timeout(
"discard_staged_change",
tools::fork::discard_staged_change(self.state.clone(), params),
)
.await
.map(json)
.map_err(|e| to_mcp_error_for_tool("discard_staged_change", e))
}
#[tool(
name = "screenshot_sheet",
description = "Capture a visual screenshot of a spreadsheet region as PNG. \
Returns file URI. Max range: 100 rows x 30 columns. Default: A1:M40."
)]
pub async fn screenshot_sheet(
&self,
Parameters(params): Parameters<tools::fork::ScreenshotSheetParams>,
) -> Result<rmcp::model::CallToolResult, McpError> {
use base64::Engine;
use rmcp::model::Content;
self.ensure_recalc_enabled("screenshot_sheet")
.map_err(|e| to_mcp_error_for_tool("screenshot_sheet", e))?;
let result = async {
let response = self
.run_tool_with_timeout(
"screenshot_sheet",
tools::fork::screenshot_sheet(self.state.clone(), params),
)
.await?;
let mut content = Vec::new();
let fs_path = response
.output_path
.strip_prefix("file://")
.ok_or_else(|| anyhow!("unexpected screenshot output_path"))?;
let bytes = tokio::fs::read(fs_path)
.await
.map_err(|e| anyhow!("failed to read screenshot: {}", e))?;
if let Some(limit) = self.state.config().max_response_bytes() {
let encoded_len = bytes.len().div_ceil(3) * 4;
let meta = serde_json::to_vec(&response)
.map_err(|e| anyhow!("failed to serialize response: {}", e))?;
let estimated = encoded_len + meta.len() + response.output_path.len();
if estimated > limit {
return Err(
ResponseTooLargeError::new("screenshot_sheet", estimated, limit).into(),
);
}
}
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
content.push(Content::image(data, "image/png"));
content.push(Content::text(response.output_path.clone()));
let structured_content = to_pruned_value(&response)
.map_err(|e| anyhow!("failed to serialize response: {}", e))?;
Ok(rmcp::model::CallToolResult {
content,
structured_content: Some(structured_content),
is_error: Some(false),
meta: None,
})
}
.await;
result.map_err(|e| to_mcp_error_for_tool("screenshot_sheet", e))
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for SpreadsheetServer {
fn get_info(&self) -> ServerInfo {
let recalc_enabled = {
#[cfg(feature = "recalc")]
{
self.state.config().recalc_enabled
}
#[cfg(not(feature = "recalc"))]
{
false
}
};
let vba_enabled = self.state.config().vba_enabled;
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation::from_build_env(),
instructions: Some(build_instructions(recalc_enabled, vba_enabled)),
..ServerInfo::default()
}
}
}
fn to_mcp_error_for_tool(tool: &str, error: anyhow::Error) -> McpError {
if error.is::<ToolDisabledError>() || error.is::<ResponseTooLargeError>() {
return McpError::invalid_request(error.to_string(), None);
}
if let Some(inv) = error.downcast_ref::<InvalidParamsError>() {
let example = tool_minimal_example(tool);
let variants = tool_variants(tool, inv.message())
.unwrap_or_default()
.into_iter()
.map(|s| s.to_string())
.collect::<Vec<_>>();
let msg = format_invalid_params_message(
tool,
inv.message(),
inv.path(),
if variants.is_empty() {
None
} else {
Some(&variants)
},
example,
);
return McpError::invalid_params(msg, None);
}
if let Some(serde_err) = error.downcast_ref::<serde_json::Error>() {
let problem = serde_err.to_string();
let path = infer_path_for_tool(tool, &problem);
let mut variants = extract_expected_variants(&problem);
if variants.is_empty()
&& let Some(extra) = tool_variants(tool, &problem)
{
variants = extra.into_iter().map(|s| s.to_string()).collect();
}
let example = tool_minimal_example(tool);
let msg = format_invalid_params_message(
tool,
&problem,
path.as_deref(),
if variants.is_empty() {
None
} else {
Some(&variants)
},
example,
);
return McpError::invalid_params(msg, None);
}
let problem = error.to_string();
if looks_like_invalid_params(&problem) {
let path = infer_path_for_tool(tool, &problem);
let variants = tool_variants(tool, &problem)
.unwrap_or_default()
.into_iter()
.map(|s| s.to_string())
.collect::<Vec<_>>();
let example = tool_minimal_example(tool);
let msg = format_invalid_params_message(
tool,
&problem,
path.as_deref(),
if variants.is_empty() {
None
} else {
Some(&variants)
},
example,
);
return McpError::invalid_params(msg, None);
}
McpError::internal_error(problem, None)
}
fn format_invalid_params_message(
tool: &str,
problem: &str,
path: Option<&str>,
variants: Option<&[String]>,
example: Option<&'static str>,
) -> String {
let mut out = String::new();
out.push_str(&format!("Invalid params for tool '{tool}': {problem}"));
if let Some(path) = path {
out.push_str(&format!("\npath: {path}"));
}
if let Some(variants) = variants
&& !variants.is_empty()
{
out.push_str("\nvalid variants: ");
out.push_str(&variants.join(", "));
}
if let Some(example) = example {
out.push_str("\nexample: ");
out.push_str(example);
}
out
}
fn tool_minimal_example(tool: &str) -> Option<&'static str> {
match tool {
"structure_batch" => Some(
r#"{"fork_id":"<fork_id>","ops":[{"kind":"insert_rows","sheet_name":"Sheet1","at_row":2,"count":1}],"mode":"apply"}"#,
),
"style_batch" => Some(
r#"{"fork_id":"<fork_id>","ops":[{"sheet_name":"Sheet1","target":{"kind":"range","range":"A1:A1"},"patch":{"fill":{"kind":"pattern","pattern_type":"solid","foreground_color":"FFFF0000"}},"op_mode":"merge"}],"mode":"apply"}"#,
),
"edit_batch" => Some(
r#"{"fork_id":"<fork_id>","sheet_name":"Sheet1","edits":["A1=100","B2==SUM(A1:A2)"]}"#,
),
"sheet_layout_batch" => Some(
r#"{"fork_id":"<fork_id>","ops":[{"kind":"freeze_panes","sheet_name":"Dashboard","freeze_rows":1,"freeze_cols":1}],"mode":"apply"}"#,
),
"rules_batch" => Some(
r#"{"fork_id":"<fork_id>","ops":[{"kind":"set_data_validation","sheet_name":"Inputs","target_range":"B3:B100","validation":{"kind":"list","formula1":"=Lists!$A$1:$A$10","allow_blank":false}}],"mode":"apply"}"#,
),
_ => None,
}
}
fn infer_path_for_tool(tool: &str, problem: &str) -> Option<String> {
let p = problem.to_ascii_lowercase();
match tool {
"structure_batch" => {
if p.contains("structure op") && (p.contains("kind") || p.contains("op")) {
return Some("ops[0].kind".to_string());
}
if p.contains("missing field `kind`") || p.contains("missing field kind") {
return Some("ops[0].kind".to_string());
}
None
}
"style_batch" => {
if p.contains("fillpatch") || p.contains("fillpatchinput") {
return Some("ops[0].patch.fill.kind".to_string());
}
if p.contains("styletarget") && p.contains("kind") {
return Some("ops[0].target.kind".to_string());
}
None
}
"sheet_layout_batch" => {
if p.contains("missing field `kind`") || p.contains("missing field kind") {
return Some("ops[0].kind".to_string());
}
if p.contains("sheetlayoutop") && p.contains("kind") {
return Some("ops[0].kind".to_string());
}
if p.contains("unknown variant") && p.contains("apply") && p.contains("preview") {
return Some("mode".to_string());
}
if p.contains("mode") && p.contains("invalid") {
return Some("mode".to_string());
}
None
}
"rules_batch" => {
if p.contains("missing field `kind`") || p.contains("missing field kind") {
return Some("ops[0].kind".to_string());
}
if p.contains("rulesop") && p.contains("kind") {
return Some("ops[0].kind".to_string());
}
if p.contains("datavalidationkind") {
return Some("ops[0].validation.kind".to_string());
}
if p.contains("conditionalformat") && p.contains("operator") {
return Some("ops[0].rule.operator".to_string());
}
if p.contains("conditionalformatrulespec") && p.contains("kind") {
return Some("ops[0].rule.kind".to_string());
}
if p.contains("unknown variant") && p.contains("apply") && p.contains("preview") {
return Some("mode".to_string());
}
if p.contains("mode") && p.contains("invalid") {
return Some("mode".to_string());
}
None
}
_ => None,
}
}
fn tool_variants(tool: &str, problem: &str) -> Option<Vec<&'static str>> {
let p = problem.to_ascii_lowercase();
match tool {
"structure_batch" => {
if p.contains("structure op")
|| p.contains("structureop")
|| (p.contains("unknown variant") && p.contains("kind"))
{
return Some(vec![
"insert_rows",
"delete_rows",
"insert_cols",
"delete_cols",
"rename_sheet",
"create_sheet",
"delete_sheet",
"copy_range",
"move_range",
]);
}
None
}
"style_batch" => {
if p.contains("fill") || p.contains("fillpatch") || p.contains("fillpatchinput") {
return Some(vec!["pattern", "gradient"]);
}
if p.contains("op_mode") || p.contains("op mode") {
return Some(vec!["merge", "set", "clear"]);
}
None
}
"sheet_layout_batch" => {
if p.contains("sheetlayoutop")
|| p.contains("sheet layout op")
|| (p.contains("unknown variant") && p.contains("kind"))
|| p.contains("missing field `kind`")
|| p.contains("missing field kind")
{
return Some(vec![
"freeze_panes",
"set_zoom",
"set_gridlines",
"set_page_margins",
"set_page_setup",
"set_print_area",
"set_page_breaks",
]);
}
None
}
"rules_batch" => {
if p.contains("rulesop")
|| p.contains("rules op")
|| (p.contains("unknown variant") && p.contains("kind"))
|| p.contains("missing field `kind`")
|| p.contains("missing field kind")
{
return Some(vec![
"set_data_validation",
"add_conditional_format",
"set_conditional_format",
"clear_conditional_formats",
]);
}
if p.contains("datavalidationkind") {
return Some(vec!["list", "whole", "decimal", "date", "custom"]);
}
if p.contains("conditionalformatrulespec") {
return Some(vec!["cell_is", "expression"]);
}
if p.contains("conditionalformatoperator") {
return Some(vec![
"less_than",
"less_than_or_equal",
"greater_than",
"greater_than_or_equal",
"equal",
"not_equal",
"between",
"not_between",
]);
}
None
}
_ => None,
}
}
fn looks_like_invalid_params(problem: &str) -> bool {
let p = problem.to_ascii_lowercase();
if p.contains("missing field")
|| p.contains("unknown field")
|| p.contains("unknown variant")
|| p.contains("did not match any variant")
|| p.contains("must be an object")
{
return true;
}
if p.contains("invalid shorthand edit") {
return true;
}
if p.contains("invalid mode") {
return true;
}
false
}
fn extract_expected_variants(problem: &str) -> Vec<String> {
static EXPECTED_TAIL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"expected(?: one of)? (?P<tail>.*)$").expect("regex"));
static BACKTICK_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"`([^`]+)`").expect("regex"));
let Some(caps) = EXPECTED_TAIL_RE.captures(problem) else {
return Vec::new();
};
let tail = caps.name("tail").map(|m| m.as_str()).unwrap_or("");
BACKTICK_RE
.captures_iter(tail)
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
.collect()
}
#[cfg(all(test, feature = "recalc"))]
mod typed_errors_tests {
use super::to_mcp_error_for_tool;
use crate::tools;
use rmcp::model::ErrorCode;
use serde_json::json;
#[test]
fn structure_batch_missing_kind_or_op_is_invalid_params_with_example_and_variants() {
let bad = json!({
"fork_id": "f1",
"ops": [
{ "sheet_name": "Sheet1", "at_row": 2, "count": 1 }
]
});
let err =
serde_json::from_value::<tools::fork::StructureBatchParamsInput>(bad).unwrap_err();
let mcp = to_mcp_error_for_tool("structure_batch", err.into());
assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
assert!(mcp.message.to_ascii_lowercase().contains("example:"));
assert!(mcp.message.contains("insert_rows"));
assert!(mcp.message.to_ascii_lowercase().contains("valid variants"));
}
#[test]
fn style_batch_fill_missing_kind_is_invalid_params_with_example_and_variants() {
let bad = json!({
"fork_id": "f1",
"ops": [
{
"sheet_name": "Sheet1",
"target": { "kind": "range", "range": "A1:A1" },
"patch": {
"fill": { "pattern_type": "solid", "foreground_color": "FFFF0000" }
}
}
]
});
let err = serde_json::from_value::<tools::fork::StyleBatchParamsInput>(bad).unwrap_err();
let mcp = to_mcp_error_for_tool("style_batch", err.into());
assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
assert!(mcp.message.to_ascii_lowercase().contains("example:"));
assert!(mcp.message.contains("pattern"));
assert!(mcp.message.to_ascii_lowercase().contains("valid variants"));
}
#[test]
fn edit_batch_shorthand_missing_equals_is_invalid_params_with_example() {
let params = tools::write_normalize::EditBatchParamsInput {
fork_id: "f1".to_string(),
sheet_name: "Sheet1".to_string(),
edits: vec![tools::write_normalize::CellEditInput::Shorthand(
"A1".to_string(),
)],
};
let err = tools::write_normalize::normalize_edit_batch(params).unwrap_err();
let mcp = to_mcp_error_for_tool("edit_batch", err);
assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
assert!(mcp.message.to_ascii_lowercase().contains("example:"));
assert!(mcp.message.contains("A1=100"));
}
#[test]
fn sheet_layout_batch_missing_kind_is_invalid_params_with_example_and_variants() {
let bad = json!({
"fork_id": "f1",
"ops": [
{ "sheet_name": "Dashboard", "freeze_rows": 1, "freeze_cols": 1 }
],
"mode": "apply"
});
let err =
serde_json::from_value::<tools::sheet_layout::SheetLayoutBatchParams>(bad).unwrap_err();
let mcp = to_mcp_error_for_tool("sheet_layout_batch", err.into());
assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
assert!(mcp.message.to_ascii_lowercase().contains("example:"));
assert!(mcp.message.to_ascii_lowercase().contains("valid variants"));
assert!(mcp.message.contains("freeze_panes"));
}
#[test]
fn rules_batch_missing_kind_is_invalid_params_with_example_and_variants() {
let bad = json!({
"fork_id": "f1",
"ops": [
{
"sheet_name": "Inputs",
"target_range": "B3:B10",
"validation": { "kind": "list", "formula1": "=Lists!$A$1:$A$10" }
}
],
"mode": "apply"
});
let err = serde_json::from_value::<tools::rules_batch::RulesBatchParams>(bad).unwrap_err();
let mcp = to_mcp_error_for_tool("rules_batch", err.into());
assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
assert!(mcp.message.to_ascii_lowercase().contains("example:"));
assert!(mcp.message.to_ascii_lowercase().contains("valid variants"));
assert!(mcp.message.contains("set_data_validation"));
}
#[test]
fn rules_batch_invalid_mode_is_invalid_params_with_example_and_path() {
let bad = json!({
"fork_id": "f1",
"ops": [
{
"kind": "set_data_validation",
"sheet_name": "Inputs",
"target_range": "B3:B10",
"validation": { "kind": "list", "formula1": "=Lists!$A$1:$A$10" }
}
],
"mode": "maybe"
});
let err = serde_json::from_value::<tools::rules_batch::RulesBatchParams>(bad).unwrap_err();
let mcp = to_mcp_error_for_tool("rules_batch", err.into());
assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
assert!(mcp.message.to_ascii_lowercase().contains("example:"));
assert!(mcp.message.to_ascii_lowercase().contains("path: mode"));
}
}
#[derive(Debug, Error)]
#[error("tool '{tool_name}' is disabled by server configuration")]
struct ToolDisabledError {
tool_name: String,
}
impl ToolDisabledError {
fn new(tool_name: &str) -> Self {
Self {
tool_name: tool_name.to_ascii_lowercase(),
}
}
}
#[derive(Debug, Error)]
#[error(
"tool '{tool_name}' response too large ({size} bytes > {limit} bytes); reduce request size or page results"
)]
struct ResponseTooLargeError {
tool_name: String,
size: usize,
limit: usize,
}
impl ResponseTooLargeError {
fn new(tool_name: &str, size: usize, limit: usize) -> Self {
Self {
tool_name: tool_name.to_ascii_lowercase(),
size,
limit,
}
}
}
#[derive(Debug, Error)]
#[error("VBA tools are disabled (set SPREADSHEET_MCP_VBA_ENABLED=true)")]
struct VbaDisabledError;
#[cfg(feature = "recalc")]
#[derive(Debug, Error)]
#[error("recalc/write tools are disabled (set SPREADSHEET_MCP_RECALC_ENABLED=true)")]
struct RecalcDisabledError;