Skip to main content

spreadsheet_mcp/
server.rs

1use crate::config::ServerConfig;
2use crate::errors::InvalidParamsError;
3use crate::model::{
4    CloseWorkbookResponse, FindFormulaResponse, FindValueResponse, FormulaTraceResponse,
5    ManifestStubResponse, NamedRangesResponse, RangeValuesResponse, ReadTableResponse,
6    SheetFormulaMapResponse, SheetListResponse, SheetOverviewResponse, SheetPageResponse,
7    SheetStatisticsResponse, SheetStylesResponse, TableProfileResponse, VolatileScanResponse,
8    WorkbookDescription, WorkbookListResponse, WorkbookStyleSummaryResponse,
9    WorkbookSummaryResponse,
10};
11use crate::response_prune::Pruned;
12#[cfg(feature = "recalc")]
13use crate::response_prune::to_pruned_value;
14use crate::state::AppState;
15use crate::tools;
16use anyhow::{Result, anyhow};
17use rmcp::{
18    ErrorData as McpError, Json as McpJson, ServerHandler, ServiceExt,
19    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
20    model::{Implementation, ServerCapabilities, ServerInfo},
21    tool, tool_handler, tool_router,
22    transport::stdio,
23};
24use serde::Serialize;
25use std::future::Future;
26use std::sync::Arc;
27use thiserror::Error;
28use {once_cell::sync::Lazy, regex::Regex};
29
30type Json<T> = McpJson<Pruned<T>>;
31
32fn json<T>(value: T) -> Json<T> {
33    McpJson(Pruned(value))
34}
35
36const BASE_INSTRUCTIONS: &str = "\
37Spreadsheet MCP: optimized for spreadsheet analysis.
38
39WORKFLOW:
401) list_workbooks → list_sheets → workbook_summary for orientation
412) sheet_overview for region detection (ids/bounds/kind/confidence)
423) For structured data: table_profile for quick column sense, then read_table with region_id/range, filters, sampling
434) For spot checks: range_values or find_value (label mode for key-value sheets)
44
45TOOL SELECTION:
46- table_profile: Fast column/type summary before wide reads.
47- read_table: Structured table extraction. Prefer region_id or tight range; use limit + sample_mode.
48- sheet_formula_map: Get formula overview. Use limit param for large sheets (e.g., limit=10). \
49Use sort_by='complexity' for most complex formulas first, or 'count' for most repeated. \
50Use range param to scope to specific region.
51- formula_trace: Trace ONE cell's precedents/dependents. Use AFTER formula_map \
52to dive deep on specific outputs (e.g., trace the total cell to understand calc flow).
53- sheet_page: Raw cell dump. Use ONLY when region detection fails or for \
54unstructured sheets. Prefer read_table for tabular data.
55- find_value with mode='label': For key-value layouts (label in col A, value in col B). \
56Use direction='right' or 'below' hints.
57- find_formula: Search formulas. Default returns no context and only first 50 matches. \
58Use include_context=true for header+cell snapshots, and use limit/offset to page.
59
60OUTPUT DEFAULTS (token-dense profile):
61- read_table defaults to format=csv (flat string). Use format=values for raw arrays, or format=json for typed cells.
62- range_values defaults to format=values. Use format=csv or format=json as needed.
63- sheet_page defaults to format=compact; set format=full for per-cell objects.
64- table_profile defaults to summary_only=true (no samples). Set summary_only=false to include sample rows.
65- sheet_statistics defaults to summary_only=true (no samples). Set summary_only=false to include samples.
66- sheet_styles defaults to summary_only=true (no descriptors/ranges/examples). Use include_descriptor/include_ranges/include_example_cells.
67- workbook_style_summary defaults to summary_only=true (no theme/conditional formats/descriptors). Use include_theme/include_conditional_formats/include_descriptor/include_example_cells.
68- sheet_formula_map defaults to summary_only=true (addresses hidden). Set include_addresses=true to show cell addresses.
69- find_value defaults to context=none (no neighbors/row_context). Use context=neighbors, context=row, or context=both.
70- scan_volatiles defaults to summary_only=true (addresses hidden). Set include_addresses=true to list addresses.
71- list_workbooks defaults to include_paths=false (no paths/caps). Set include_paths=true to show them.
72- list_sheets defaults to include_bounds=false (no row/column counts). Set include_bounds=true to show them.
73- workbook_summary defaults to summary_only=true (no entry points/named ranges). Set summary_only=false or include_entry_points/include_named_ranges.
74- Pagination fields (next_offset/next_start_row) only appear when more data exists.
75
76RANGES: Use A1 notation (e.g., A1:C10). Prefer region_id when available.
77
78DATES: Cells with date formats return ISO-8601 strings (YYYY-MM-DD).
79
80Keep payloads small. Page through large sheets.";
81
82const VBA_INSTRUCTIONS: &str = "
83
84VBA TOOLS (enabled):
85Read-only VBA project inspection for .xlsm workbooks.
86
87WORKFLOW:
881) list_workbooks → describe_workbook to find candidate .xlsm
892) vba_project_summary to list modules
903) vba_module_source to page module code
91
92TOOLS:
93- vba_project_summary: Parse and summarize the embedded vbaProject.bin (modules + metadata).
94- vba_module_source: Return paged source for one module (use offset_lines/limit_lines).
95
96SAFETY:
97- Treat VBA as untrusted code. Tools only read and return text.
98- Responses are size-limited; page through module source.
99";
100
101const WRITE_INSTRUCTIONS: &str = "
102
103WRITE/RECALC TOOLS (enabled):
104Fork-based editing allows 'what-if' analysis without modifying original files.
105
106WORKFLOW:
1071) create_fork: Create editable copy of a workbook. Returns fork_id.
1082) Optional: checkpoint_fork before large edits.
1093) edit_batch/transform_batch/style_batch/structure_batch/apply_formula_pattern/sheet_layout_batch/rules_batch/column_size_batch: Apply edits to the fork.
1104) recalculate: Trigger the configured recalc backend to recompute all formulas.
1115) get_changeset: Diff fork against original. Use filters/limit/offset to keep it small.
112   Optional: screenshot_sheet to capture a visual view of a range (original or fork).
1136) save_fork: Write changes to file.
1147) discard_fork: Delete fork without saving.
115
116SAFETY:
117- checkpoint_fork before large/structural edits; restore_checkpoint to rollback if needed.
118- 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.
119
120TOOL DETAILS:
121- create_fork: Only .xlsx supported. Returns fork_id for subsequent operations.
122- edit_batch: {fork_id, sheet_name, edits:[{address, value, is_formula} | `A1=100`]}. \
123Shorthand edits like `A1=100` or `B2==SUM(A1:A2)` are accepted. \
124Leading '=' in value/formula is accepted and stripped; prefer formula or is_formula=true for clarity.
125- transform_batch: Range-first clear/fill/replace. Prefer for bulk edits (blank/fill/rename) to avoid per-cell edit_batch bloat.
126- recalculate: Required after edit_batch to update formula results. \
127May take several seconds for complex workbooks.
128- get_changeset: Returns a paged diff + summary. Use limit/offset to page. \
129Use include_types/exclude_types/include_subtypes/exclude_subtypes to filter (e.g. exclude_subtypes=['recalc_result']). \
130Use summary_only=true when you only need counts.
131- screenshot_sheet: {workbook_or_fork_id, sheet_name, range?}. Renders a cropped PNG for inspecting an area visually.
132  workbook_or_fork_id may be either a real workbook_id OR a fork_id (to screenshot an edited fork).
133  Returns a file:// URI under screenshot_dir (default: <workspace_root>/screenshots).
134  If path mapping is configured (--path-map), client_output_path is included to help locate the file on the host.
135  DO NOT call save_fork just to get a screenshot.
136  If formulas changed, run recalculate on the fork first.
137- save_fork: Requires target_path for new file location.
138  If target_path is relative, it is resolved under workspace_root (Docker default: `/data`).
139  If target_path is absolute and matches a configured path mapping, it is mapped to the internal path automatically.
140  If path mapping is configured (--path-map), client_saved_to is included.
141  Overwriting original requires server --allow-overwrite flag.
142  Use drop_fork=false to keep fork active after saving (default: true drops fork).
143  Validates base file unchanged since fork creation.
144- get_edits: List all edits applied to a fork (before recalculate).
145- list_forks: See all active forks.
146- checkpoint_fork: Snapshot a fork to a checkpoint for high-fidelity undo.
147- list_checkpoints: List checkpoints for a fork.
148- restore_checkpoint: Restore a fork to a checkpoint (overwrites fork file; clears newer staged changes).
149- delete_checkpoint: Delete a checkpoint.
150- list_staged_changes: List staged (previewed) changes for a fork.
151- apply_staged_change: Apply a staged change to the fork.
152- discard_staged_change: Discard a staged change.
153
154BEST PRACTICES:
155- Always recalculate after edit_batch before get_changeset.
156- Review changeset before save_fork to verify expected changes.
157- Use screenshot_sheet for quick visual inspection; save_fork is ONLY for exporting a workbook file.
158- Discard forks when done to free resources (fork TTL is disabled by default).
159- For large edits, batch multiple cells in single edit_batch call.";
160
161fn build_instructions(recalc_enabled: bool, vba_enabled: bool) -> String {
162    let mut instructions = BASE_INSTRUCTIONS.to_string();
163
164    if vba_enabled {
165        instructions.push_str(VBA_INSTRUCTIONS);
166    } else {
167        instructions
168            .push_str("\n\nVBA tools disabled. Set SPREADSHEET_MCP_VBA_ENABLED=true to enable.");
169    }
170
171    if recalc_enabled {
172        instructions.push_str(WRITE_INSTRUCTIONS);
173    } else {
174        instructions.push_str("\n\nRead-only mode. Write/recalc tools disabled.");
175    }
176    instructions
177}
178
179#[derive(Clone)]
180pub struct SpreadsheetServer {
181    state: Arc<AppState>,
182    tool_router: ToolRouter<SpreadsheetServer>,
183}
184
185impl SpreadsheetServer {
186    pub async fn new(config: Arc<ServerConfig>) -> Result<Self> {
187        config.ensure_workspace_root()?;
188        let state = Arc::new(AppState::new(config));
189        Ok(Self::from_state(state))
190    }
191
192    pub fn from_state(state: Arc<AppState>) -> Self {
193        #[allow(unused_mut)]
194        let mut router = Self::tool_router();
195
196        #[cfg(feature = "recalc")]
197        {
198            router.merge(Self::fork_tool_router());
199        }
200
201        if state.config().vba_enabled {
202            router.merge(Self::vba_tool_router());
203        }
204
205        Self {
206            state,
207            tool_router: router,
208        }
209    }
210
211    pub async fn run_stdio(self) -> Result<()> {
212        let service = self
213            .serve(stdio())
214            .await
215            .inspect_err(|error| tracing::error!("serving error: {:?}", error))?;
216        service.waiting().await?;
217        Ok(())
218    }
219
220    pub async fn run(self) -> Result<()> {
221        self.run_stdio().await
222    }
223
224    fn ensure_tool_enabled(&self, tool: &str) -> Result<()> {
225        tracing::info!(tool = tool, "tool invocation requested");
226        if self.state.config().is_tool_enabled(tool) {
227            Ok(())
228        } else {
229            Err(ToolDisabledError::new(tool).into())
230        }
231    }
232
233    fn ensure_vba_enabled(&self, tool: &str) -> Result<()> {
234        self.ensure_tool_enabled(tool)?;
235        if self.state.config().vba_enabled {
236            Ok(())
237        } else {
238            Err(VbaDisabledError.into())
239        }
240    }
241
242    #[cfg(feature = "recalc")]
243    fn ensure_recalc_enabled(&self, tool: &str) -> Result<()> {
244        self.ensure_tool_enabled(tool)?;
245        if self.state.config().recalc_enabled {
246            Ok(())
247        } else {
248            Err(RecalcDisabledError.into())
249        }
250    }
251
252    async fn run_tool_with_timeout<T, F>(&self, tool: &str, fut: F) -> Result<T>
253    where
254        F: Future<Output = Result<T>>,
255        T: Serialize,
256    {
257        let result = if let Some(timeout_duration) = self.state.config().tool_timeout() {
258            match tokio::time::timeout(timeout_duration, fut).await {
259                Ok(result) => result,
260                Err(_) => Err(anyhow!(
261                    "tool '{}' timed out after {}ms",
262                    tool,
263                    timeout_duration.as_millis()
264                )),
265            }
266        } else {
267            fut.await
268        }?;
269
270        self.ensure_response_size(tool, &result)?;
271        Ok(result)
272    }
273
274    fn ensure_response_size<T: Serialize>(&self, tool: &str, value: &T) -> Result<()> {
275        let Some(limit) = self.state.config().max_response_bytes() else {
276            return Ok(());
277        };
278        let payload = serde_json::to_vec(value)
279            .map_err(|e| anyhow!("failed to serialize response for {}: {}", tool, e))?;
280        if payload.len() > limit {
281            return Err(ResponseTooLargeError::new(tool, payload.len(), limit).into());
282        }
283        Ok(())
284    }
285}
286
287#[tool_router]
288impl SpreadsheetServer {
289    #[tool(
290        name = "list_workbooks",
291        description = "List spreadsheet files in the workspace"
292    )]
293    pub async fn list_workbooks(
294        &self,
295        Parameters(params): Parameters<tools::ListWorkbooksParams>,
296    ) -> Result<Json<WorkbookListResponse>, McpError> {
297        self.ensure_tool_enabled("list_workbooks")
298            .map_err(|e| to_mcp_error_for_tool("list_workbooks", e))?;
299        self.run_tool_with_timeout(
300            "list_workbooks",
301            tools::list_workbooks(self.state.clone(), params),
302        )
303        .await
304        .map(json)
305        .map_err(|e| to_mcp_error_for_tool("list_workbooks", e))
306    }
307
308    #[tool(name = "describe_workbook", description = "Describe workbook metadata")]
309    pub async fn describe_workbook(
310        &self,
311        Parameters(params): Parameters<tools::DescribeWorkbookParams>,
312    ) -> Result<Json<WorkbookDescription>, McpError> {
313        self.ensure_tool_enabled("describe_workbook")
314            .map_err(|e| to_mcp_error_for_tool("describe_workbook", e))?;
315        self.run_tool_with_timeout(
316            "describe_workbook",
317            tools::describe_workbook(self.state.clone(), params),
318        )
319        .await
320        .map(json)
321        .map_err(|e| to_mcp_error_for_tool("describe_workbook", e))
322    }
323
324    #[tool(
325        name = "workbook_summary",
326        description = "Summarize workbook regions and entry points"
327    )]
328    pub async fn workbook_summary(
329        &self,
330        Parameters(params): Parameters<tools::WorkbookSummaryParams>,
331    ) -> Result<Json<WorkbookSummaryResponse>, McpError> {
332        self.ensure_tool_enabled("workbook_summary")
333            .map_err(|e| to_mcp_error_for_tool("workbook_summary", e))?;
334        self.run_tool_with_timeout(
335            "workbook_summary",
336            tools::workbook_summary(self.state.clone(), params),
337        )
338        .await
339        .map(json)
340        .map_err(|e| to_mcp_error_for_tool("workbook_summary", e))
341    }
342
343    #[tool(name = "list_sheets", description = "List sheets with summaries")]
344    pub async fn list_sheets(
345        &self,
346        Parameters(params): Parameters<tools::ListSheetsParams>,
347    ) -> Result<Json<SheetListResponse>, McpError> {
348        self.ensure_tool_enabled("list_sheets")
349            .map_err(|e| to_mcp_error_for_tool("list_sheets", e))?;
350        self.run_tool_with_timeout(
351            "list_sheets",
352            tools::list_sheets(self.state.clone(), params),
353        )
354        .await
355        .map(json)
356        .map_err(|e| to_mcp_error_for_tool("list_sheets", e))
357    }
358
359    #[tool(
360        name = "sheet_overview",
361        description = "Get narrative overview for a sheet"
362    )]
363    pub async fn sheet_overview(
364        &self,
365        Parameters(params): Parameters<tools::SheetOverviewParams>,
366    ) -> Result<Json<SheetOverviewResponse>, McpError> {
367        self.ensure_tool_enabled("sheet_overview")
368            .map_err(|e| to_mcp_error_for_tool("sheet_overview", e))?;
369        self.run_tool_with_timeout(
370            "sheet_overview",
371            tools::sheet_overview(self.state.clone(), params),
372        )
373        .await
374        .map(json)
375        .map_err(|e| to_mcp_error_for_tool("sheet_overview", e))
376    }
377
378    #[tool(name = "sheet_page", description = "Page through sheet cells")]
379    pub async fn sheet_page(
380        &self,
381        Parameters(params): Parameters<tools::SheetPageParams>,
382    ) -> Result<Json<SheetPageResponse>, McpError> {
383        self.ensure_tool_enabled("sheet_page")
384            .map_err(|e| to_mcp_error_for_tool("sheet_page", e))?;
385        self.run_tool_with_timeout("sheet_page", tools::sheet_page(self.state.clone(), params))
386            .await
387            .map(json)
388            .map_err(|e| to_mcp_error_for_tool("sheet_page", e))
389    }
390
391    #[tool(name = "find_value", description = "Search cell values or labels")]
392    pub async fn find_value(
393        &self,
394        Parameters(params): Parameters<tools::FindValueParams>,
395    ) -> Result<Json<FindValueResponse>, McpError> {
396        self.ensure_tool_enabled("find_value")
397            .map_err(|e| to_mcp_error_for_tool("find_value", e))?;
398        self.run_tool_with_timeout("find_value", tools::find_value(self.state.clone(), params))
399            .await
400            .map(json)
401            .map_err(|e| to_mcp_error_for_tool("find_value", e))
402    }
403
404    #[tool(
405        name = "read_table",
406        description = "Read structured data from a range or table"
407    )]
408    pub async fn read_table(
409        &self,
410        Parameters(params): Parameters<tools::ReadTableParams>,
411    ) -> Result<Json<ReadTableResponse>, McpError> {
412        self.ensure_tool_enabled("read_table")
413            .map_err(|e| to_mcp_error_for_tool("read_table", e))?;
414        self.run_tool_with_timeout("read_table", tools::read_table(self.state.clone(), params))
415            .await
416            .map(json)
417            .map_err(|e| to_mcp_error_for_tool("read_table", e))
418    }
419
420    #[tool(name = "table_profile", description = "Profile a region or table")]
421    pub async fn table_profile(
422        &self,
423        Parameters(params): Parameters<tools::TableProfileParams>,
424    ) -> Result<Json<TableProfileResponse>, McpError> {
425        self.ensure_tool_enabled("table_profile")
426            .map_err(|e| to_mcp_error_for_tool("table_profile", e))?;
427        self.run_tool_with_timeout(
428            "table_profile",
429            tools::table_profile(self.state.clone(), params),
430        )
431        .await
432        .map(json)
433        .map_err(|e| to_mcp_error_for_tool("table_profile", e))
434    }
435
436    #[tool(
437        name = "range_values",
438        description = "Fetch raw values for specific ranges"
439    )]
440    pub async fn range_values(
441        &self,
442        Parameters(params): Parameters<tools::RangeValuesParams>,
443    ) -> Result<Json<RangeValuesResponse>, McpError> {
444        self.ensure_tool_enabled("range_values")
445            .map_err(|e| to_mcp_error_for_tool("range_values", e))?;
446        self.run_tool_with_timeout(
447            "range_values",
448            tools::range_values(self.state.clone(), params),
449        )
450        .await
451        .map(json)
452        .map_err(|e| to_mcp_error_for_tool("range_values", e))
453    }
454
455    #[tool(
456        name = "sheet_statistics",
457        description = "Get aggregated sheet statistics"
458    )]
459    pub async fn sheet_statistics(
460        &self,
461        Parameters(params): Parameters<tools::SheetStatisticsParams>,
462    ) -> Result<Json<SheetStatisticsResponse>, McpError> {
463        self.ensure_tool_enabled("sheet_statistics")
464            .map_err(|e| to_mcp_error_for_tool("sheet_statistics", e))?;
465        self.run_tool_with_timeout(
466            "sheet_statistics",
467            tools::sheet_statistics(self.state.clone(), params),
468        )
469        .await
470        .map(json)
471        .map_err(|e| to_mcp_error_for_tool("sheet_statistics", e))
472    }
473
474    #[tool(
475        name = "sheet_formula_map",
476        description = "Summarize formula groups across a sheet"
477    )]
478    pub async fn sheet_formula_map(
479        &self,
480        Parameters(params): Parameters<tools::SheetFormulaMapParams>,
481    ) -> Result<Json<SheetFormulaMapResponse>, McpError> {
482        self.ensure_tool_enabled("sheet_formula_map")
483            .map_err(|e| to_mcp_error_for_tool("sheet_formula_map", e))?;
484        self.run_tool_with_timeout(
485            "sheet_formula_map",
486            tools::sheet_formula_map(self.state.clone(), params),
487        )
488        .await
489        .map(json)
490        .map_err(|e| to_mcp_error_for_tool("sheet_formula_map", e))
491    }
492
493    #[tool(
494        name = "formula_trace",
495        description = "Trace formula precedents or dependents"
496    )]
497    pub async fn formula_trace(
498        &self,
499        Parameters(params): Parameters<tools::FormulaTraceParams>,
500    ) -> Result<Json<FormulaTraceResponse>, McpError> {
501        self.ensure_tool_enabled("formula_trace")
502            .map_err(|e| to_mcp_error_for_tool("formula_trace", e))?;
503        self.run_tool_with_timeout(
504            "formula_trace",
505            tools::formula_trace(self.state.clone(), params),
506        )
507        .await
508        .map(json)
509        .map_err(|e| to_mcp_error_for_tool("formula_trace", e))
510    }
511
512    #[tool(name = "named_ranges", description = "List named ranges and tables")]
513    pub async fn named_ranges(
514        &self,
515        Parameters(params): Parameters<tools::NamedRangesParams>,
516    ) -> Result<Json<NamedRangesResponse>, McpError> {
517        self.ensure_tool_enabled("named_ranges")
518            .map_err(|e| to_mcp_error_for_tool("named_ranges", e))?;
519        self.run_tool_with_timeout(
520            "named_ranges",
521            tools::named_ranges(self.state.clone(), params),
522        )
523        .await
524        .map(json)
525        .map_err(|e| to_mcp_error_for_tool("named_ranges", e))
526    }
527
528    #[tool(
529        name = "find_formula",
530        description = "Search formulas containing text. Defaults: include_context=false, limit=50; use offset for paging."
531    )]
532    pub async fn find_formula(
533        &self,
534        Parameters(params): Parameters<tools::FindFormulaParams>,
535    ) -> Result<Json<FindFormulaResponse>, McpError> {
536        self.ensure_tool_enabled("find_formula")
537            .map_err(|e| to_mcp_error_for_tool("find_formula", e))?;
538        self.run_tool_with_timeout(
539            "find_formula",
540            tools::find_formula(self.state.clone(), params),
541        )
542        .await
543        .map(json)
544        .map_err(|e| to_mcp_error_for_tool("find_formula", e))
545    }
546
547    #[tool(name = "scan_volatiles", description = "Scan for volatile formulas")]
548    pub async fn scan_volatiles(
549        &self,
550        Parameters(params): Parameters<tools::ScanVolatilesParams>,
551    ) -> Result<Json<VolatileScanResponse>, McpError> {
552        self.ensure_tool_enabled("scan_volatiles")
553            .map_err(|e| to_mcp_error_for_tool("scan_volatiles", e))?;
554        self.run_tool_with_timeout(
555            "scan_volatiles",
556            tools::scan_volatiles(self.state.clone(), params),
557        )
558        .await
559        .map(json)
560        .map_err(|e| to_mcp_error_for_tool("scan_volatiles", e))
561    }
562
563    #[tool(
564        name = "sheet_styles",
565        description = "Summarise style usage and properties for a sheet"
566    )]
567    pub async fn sheet_styles(
568        &self,
569        Parameters(params): Parameters<tools::SheetStylesParams>,
570    ) -> Result<Json<SheetStylesResponse>, McpError> {
571        self.ensure_tool_enabled("sheet_styles")
572            .map_err(|e| to_mcp_error_for_tool("sheet_styles", e))?;
573        self.run_tool_with_timeout(
574            "sheet_styles",
575            tools::sheet_styles(self.state.clone(), params),
576        )
577        .await
578        .map(json)
579        .map_err(|e| to_mcp_error_for_tool("sheet_styles", e))
580    }
581
582    #[tool(
583        name = "workbook_style_summary",
584        description = "Summarise style usage, theme colors, and conditional formats across a workbook"
585    )]
586    pub async fn workbook_style_summary(
587        &self,
588        Parameters(params): Parameters<tools::WorkbookStyleSummaryParams>,
589    ) -> Result<Json<WorkbookStyleSummaryResponse>, McpError> {
590        self.ensure_tool_enabled("workbook_style_summary")
591            .map_err(|e| to_mcp_error_for_tool("workbook_style_summary", e))?;
592        self.run_tool_with_timeout(
593            "workbook_style_summary",
594            tools::workbook_style_summary(self.state.clone(), params),
595        )
596        .await
597        .map(json)
598        .map_err(|e| to_mcp_error_for_tool("workbook_style_summary", e))
599    }
600
601    #[tool(
602        name = "get_manifest_stub",
603        description = "Generate manifest scaffold for workbook"
604    )]
605    pub async fn get_manifest_stub(
606        &self,
607        Parameters(params): Parameters<tools::ManifestStubParams>,
608    ) -> Result<Json<ManifestStubResponse>, McpError> {
609        self.ensure_tool_enabled("get_manifest_stub")
610            .map_err(|e| to_mcp_error_for_tool("get_manifest_stub", e))?;
611        self.run_tool_with_timeout(
612            "get_manifest_stub",
613            tools::get_manifest_stub(self.state.clone(), params),
614        )
615        .await
616        .map(json)
617        .map_err(|e| to_mcp_error_for_tool("get_manifest_stub", e))
618    }
619
620    #[tool(name = "close_workbook", description = "Evict a workbook from cache")]
621    pub async fn close_workbook(
622        &self,
623        Parameters(params): Parameters<tools::CloseWorkbookParams>,
624    ) -> Result<Json<CloseWorkbookResponse>, McpError> {
625        self.ensure_tool_enabled("close_workbook")
626            .map_err(|e| to_mcp_error_for_tool("close_workbook", e))?;
627        self.run_tool_with_timeout(
628            "close_workbook",
629            tools::close_workbook(self.state.clone(), params),
630        )
631        .await
632        .map(json)
633        .map_err(|e| to_mcp_error_for_tool("close_workbook", e))
634    }
635}
636
637#[tool_router(router = vba_tool_router)]
638impl SpreadsheetServer {
639    #[tool(
640        name = "vba_project_summary",
641        description = "Summarize embedded VBA project (xlsm)"
642    )]
643    pub async fn vba_project_summary(
644        &self,
645        Parameters(params): Parameters<tools::vba::VbaProjectSummaryParams>,
646    ) -> Result<Json<crate::model::VbaProjectSummaryResponse>, McpError> {
647        self.ensure_vba_enabled("vba_project_summary")
648            .map_err(|e| to_mcp_error_for_tool("vba_project_summary", e))?;
649        self.run_tool_with_timeout(
650            "vba_project_summary",
651            tools::vba::vba_project_summary(self.state.clone(), params),
652        )
653        .await
654        .map(json)
655        .map_err(|e| to_mcp_error_for_tool("vba_project_summary", e))
656    }
657
658    #[tool(
659        name = "vba_module_source",
660        description = "Read VBA module source (paged)"
661    )]
662    pub async fn vba_module_source(
663        &self,
664        Parameters(params): Parameters<tools::vba::VbaModuleSourceParams>,
665    ) -> Result<Json<crate::model::VbaModuleSourceResponse>, McpError> {
666        self.ensure_vba_enabled("vba_module_source")
667            .map_err(|e| to_mcp_error_for_tool("vba_module_source", e))?;
668        self.run_tool_with_timeout(
669            "vba_module_source",
670            tools::vba::vba_module_source(self.state.clone(), params),
671        )
672        .await
673        .map(json)
674        .map_err(|e| to_mcp_error_for_tool("vba_module_source", e))
675    }
676}
677
678#[cfg(feature = "recalc")]
679#[tool_router(router = fork_tool_router)]
680impl SpreadsheetServer {
681    #[tool(
682        name = "create_fork",
683        description = "Create a temporary editable copy of a workbook for what-if analysis"
684    )]
685    pub async fn create_fork(
686        &self,
687        Parameters(params): Parameters<tools::fork::CreateForkParams>,
688    ) -> Result<Json<tools::fork::CreateForkResponse>, McpError> {
689        self.ensure_recalc_enabled("create_fork")
690            .map_err(|e| to_mcp_error_for_tool("create_fork", e))?;
691        self.run_tool_with_timeout(
692            "create_fork",
693            tools::fork::create_fork(self.state.clone(), params),
694        )
695        .await
696        .map(json)
697        .map_err(|e| to_mcp_error_for_tool("create_fork", e))
698    }
699
700    #[tool(
701        name = "edit_batch",
702        description = "Apply batch edits (values or formulas) to a fork"
703    )]
704    pub async fn edit_batch(
705        &self,
706        Parameters(params): Parameters<tools::write_normalize::EditBatchParamsInput>,
707    ) -> Result<Json<tools::fork::EditBatchResponse>, McpError> {
708        self.ensure_recalc_enabled("edit_batch")
709            .map_err(|e| to_mcp_error_for_tool("edit_batch", e))?;
710        self.run_tool_with_timeout(
711            "edit_batch",
712            tools::fork::edit_batch(self.state.clone(), params),
713        )
714        .await
715        .map(json)
716        .map_err(|e| to_mcp_error_for_tool("edit_batch", e))
717    }
718
719    #[tool(
720        name = "transform_batch",
721        description = "Range-oriented transforms for a fork (clear/fill/replace). Supports targets by range, region_id, or explicit cells. \
722Mode: preview or apply (default apply)."
723    )]
724    pub async fn transform_batch(
725        &self,
726        Parameters(params): Parameters<tools::fork::TransformBatchParams>,
727    ) -> Result<Json<tools::fork::TransformBatchResponse>, McpError> {
728        self.ensure_recalc_enabled("transform_batch")
729            .map_err(|e| to_mcp_error_for_tool("transform_batch", e))?;
730        self.run_tool_with_timeout(
731            "transform_batch",
732            tools::fork::transform_batch(self.state.clone(), params),
733        )
734        .await
735        .map(json)
736        .map_err(|e| to_mcp_error_for_tool("transform_batch", e))
737    }
738
739    #[tool(
740        name = "style_batch",
741        description = "Apply batch style edits to a fork. Supports targets by range, region_id, or explicit cells. \
742Mode: preview or apply (default apply). Op mode: merge (default), set, or clear."
743    )]
744    pub async fn style_batch(
745        &self,
746        Parameters(params): Parameters<tools::fork::StyleBatchParamsInput>,
747    ) -> Result<Json<tools::fork::StyleBatchResponse>, McpError> {
748        self.ensure_recalc_enabled("style_batch")
749            .map_err(|e| to_mcp_error_for_tool("style_batch", e))?;
750        self.run_tool_with_timeout(
751            "style_batch",
752            tools::fork::style_batch(self.state.clone(), params),
753        )
754        .await
755        .map(json)
756        .map_err(|e| to_mcp_error_for_tool("style_batch", e))
757    }
758
759    #[tool(
760        name = "column_size_batch",
761        description = "Set column widths or compute auto-widths in a fork. Targets column ranges like 'A:A' or 'A:C'. \
762Mode: preview or apply (default apply). Auto computes and sets widths immediately (persisted). \
763Note: 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."
764    )]
765    pub async fn column_size_batch(
766        &self,
767        Parameters(params): Parameters<tools::fork::ColumnSizeBatchParamsInput>,
768    ) -> Result<Json<tools::fork::ColumnSizeBatchResponse>, McpError> {
769        self.ensure_recalc_enabled("column_size_batch")
770            .map_err(|e| to_mcp_error_for_tool("column_size_batch", e))?;
771        self.run_tool_with_timeout(
772            "column_size_batch",
773            tools::fork::column_size_batch(self.state.clone(), params),
774        )
775        .await
776        .map(json)
777        .map_err(|e| to_mcp_error_for_tool("column_size_batch", e))
778    }
779
780    #[tool(
781        name = "sheet_layout_batch",
782        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)."
783    )]
784    pub async fn sheet_layout_batch(
785        &self,
786        Parameters(params): Parameters<tools::sheet_layout::SheetLayoutBatchParams>,
787    ) -> Result<Json<tools::sheet_layout::SheetLayoutBatchResponse>, McpError> {
788        self.ensure_recalc_enabled("sheet_layout_batch")
789            .map_err(|e| to_mcp_error_for_tool("sheet_layout_batch", e))?;
790        self.run_tool_with_timeout(
791            "sheet_layout_batch",
792            tools::sheet_layout::sheet_layout_batch(self.state.clone(), params),
793        )
794        .await
795        .map(json)
796        .map_err(|e| to_mcp_error_for_tool("sheet_layout_batch", e))
797    }
798
799    #[tool(
800        name = "apply_formula_pattern",
801        description = "Autofill-like formula pattern application over a target range in a fork. \
802Provide base_formula at anchor_cell, then fill across target_range. \
803Mode: preview or apply (default apply). relative_mode: excel (default), abs_cols, abs_rows. \
804fill_direction: down, right, both (default both)."
805    )]
806    pub async fn apply_formula_pattern(
807        &self,
808        Parameters(params): Parameters<tools::fork::ApplyFormulaPatternParams>,
809    ) -> Result<Json<tools::fork::ApplyFormulaPatternResponse>, McpError> {
810        self.ensure_recalc_enabled("apply_formula_pattern")
811            .map_err(|e| to_mcp_error_for_tool("apply_formula_pattern", e))?;
812        self.run_tool_with_timeout(
813            "apply_formula_pattern",
814            tools::fork::apply_formula_pattern(self.state.clone(), params),
815        )
816        .await
817        .map(json)
818        .map_err(|e| to_mcp_error_for_tool("apply_formula_pattern", e))
819    }
820
821    #[tool(
822        name = "structure_batch",
823        description = "Apply structural edits to a fork (rows/cols/sheets). \
824Mode: preview or apply (default apply). Aliases: op for kind, add_sheet for create_sheet. \
825Note: structural edits may not fully rewrite formulas/named ranges like Excel; run recalculate and review get_changeset after applying."
826    )]
827    pub async fn structure_batch(
828        &self,
829        Parameters(params): Parameters<tools::fork::StructureBatchParamsInput>,
830    ) -> Result<Json<tools::fork::StructureBatchResponse>, McpError> {
831        self.ensure_recalc_enabled("structure_batch")
832            .map_err(|e| to_mcp_error_for_tool("structure_batch", e))?;
833        self.run_tool_with_timeout(
834            "structure_batch",
835            tools::fork::structure_batch(self.state.clone(), params),
836        )
837        .await
838        .map(json)
839        .map_err(|e| to_mcp_error_for_tool("structure_batch", e))
840    }
841
842    #[tool(
843        name = "rules_batch",
844        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)."
845    )]
846    pub async fn rules_batch(
847        &self,
848        Parameters(params): Parameters<tools::rules_batch::RulesBatchParams>,
849    ) -> Result<Json<tools::rules_batch::RulesBatchResponse>, McpError> {
850        self.ensure_recalc_enabled("rules_batch")
851            .map_err(|e| to_mcp_error_for_tool("rules_batch", e))?;
852        self.run_tool_with_timeout(
853            "rules_batch",
854            tools::rules_batch::rules_batch(self.state.clone(), params),
855        )
856        .await
857        .map(json)
858        .map_err(|e| to_mcp_error_for_tool("rules_batch", e))
859    }
860
861    #[tool(name = "get_edits", description = "List all edits applied to a fork")]
862    pub async fn get_edits(
863        &self,
864        Parameters(params): Parameters<tools::fork::GetEditsParams>,
865    ) -> Result<Json<tools::fork::GetEditsResponse>, McpError> {
866        self.ensure_recalc_enabled("get_edits")
867            .map_err(|e| to_mcp_error_for_tool("get_edits", e))?;
868        self.run_tool_with_timeout(
869            "get_edits",
870            tools::fork::get_edits(self.state.clone(), params),
871        )
872        .await
873        .map(json)
874        .map_err(|e| to_mcp_error_for_tool("get_edits", e))
875    }
876
877    #[tool(
878        name = "get_changeset",
879        description = "Calculate diff between fork and base workbook. Defaults: limit=200. Supports limit/offset paging and type/subtype filters; returns summary."
880    )]
881    pub async fn get_changeset(
882        &self,
883        Parameters(params): Parameters<tools::fork::GetChangesetParams>,
884    ) -> Result<Json<tools::fork::GetChangesetResponse>, McpError> {
885        self.ensure_recalc_enabled("get_changeset")
886            .map_err(|e| to_mcp_error_for_tool("get_changeset", e))?;
887        self.run_tool_with_timeout(
888            "get_changeset",
889            tools::fork::get_changeset(self.state.clone(), params),
890        )
891        .await
892        .map(json)
893        .map_err(|e| to_mcp_error_for_tool("get_changeset", e))
894    }
895
896    #[tool(
897        name = "recalculate",
898        description = "Recalculate all formulas in a fork"
899    )]
900    pub async fn recalculate(
901        &self,
902        Parameters(params): Parameters<tools::fork::RecalculateParams>,
903    ) -> Result<Json<tools::fork::RecalculateResponse>, McpError> {
904        self.ensure_recalc_enabled("recalculate")
905            .map_err(|e| to_mcp_error_for_tool("recalculate", e))?;
906        self.run_tool_with_timeout(
907            "recalculate",
908            tools::fork::recalculate(self.state.clone(), params),
909        )
910        .await
911        .map(json)
912        .map_err(|e| to_mcp_error_for_tool("recalculate", e))
913    }
914
915    #[tool(name = "list_forks", description = "List all active forks")]
916    pub async fn list_forks(
917        &self,
918        Parameters(params): Parameters<tools::fork::ListForksParams>,
919    ) -> Result<Json<tools::fork::ListForksResponse>, McpError> {
920        self.ensure_recalc_enabled("list_forks")
921            .map_err(|e| to_mcp_error_for_tool("list_forks", e))?;
922        self.run_tool_with_timeout(
923            "list_forks",
924            tools::fork::list_forks(self.state.clone(), params),
925        )
926        .await
927        .map(json)
928        .map_err(|e| to_mcp_error_for_tool("list_forks", e))
929    }
930
931    #[tool(name = "discard_fork", description = "Discard a fork without saving")]
932    pub async fn discard_fork(
933        &self,
934        Parameters(params): Parameters<tools::fork::DiscardForkParams>,
935    ) -> Result<Json<tools::fork::DiscardForkResponse>, McpError> {
936        self.ensure_recalc_enabled("discard_fork")
937            .map_err(|e| to_mcp_error_for_tool("discard_fork", e))?;
938        self.run_tool_with_timeout(
939            "discard_fork",
940            tools::fork::discard_fork(self.state.clone(), params),
941        )
942        .await
943        .map(json)
944        .map_err(|e| to_mcp_error_for_tool("discard_fork", e))
945    }
946
947    #[tool(
948        name = "save_fork",
949        description = "Save fork changes to target path (defaults to overwriting original)"
950    )]
951    pub async fn save_fork(
952        &self,
953        Parameters(params): Parameters<tools::fork::SaveForkParams>,
954    ) -> Result<Json<tools::fork::SaveForkResponse>, McpError> {
955        self.ensure_recalc_enabled("save_fork")
956            .map_err(|e| to_mcp_error_for_tool("save_fork", e))?;
957        self.run_tool_with_timeout(
958            "save_fork",
959            tools::fork::save_fork(self.state.clone(), params),
960        )
961        .await
962        .map(json)
963        .map_err(|e| to_mcp_error_for_tool("save_fork", e))
964    }
965
966    #[tool(
967        name = "checkpoint_fork",
968        description = "Create a high-fidelity checkpoint snapshot of a fork"
969    )]
970    pub async fn checkpoint_fork(
971        &self,
972        Parameters(params): Parameters<tools::fork::CheckpointForkParams>,
973    ) -> Result<Json<tools::fork::CheckpointForkResponse>, McpError> {
974        self.ensure_recalc_enabled("checkpoint_fork")
975            .map_err(|e| to_mcp_error_for_tool("checkpoint_fork", e))?;
976        self.run_tool_with_timeout(
977            "checkpoint_fork",
978            tools::fork::checkpoint_fork(self.state.clone(), params),
979        )
980        .await
981        .map(json)
982        .map_err(|e| to_mcp_error_for_tool("checkpoint_fork", e))
983    }
984
985    #[tool(name = "list_checkpoints", description = "List checkpoints for a fork")]
986    pub async fn list_checkpoints(
987        &self,
988        Parameters(params): Parameters<tools::fork::ListCheckpointsParams>,
989    ) -> Result<Json<tools::fork::ListCheckpointsResponse>, McpError> {
990        self.ensure_recalc_enabled("list_checkpoints")
991            .map_err(|e| to_mcp_error_for_tool("list_checkpoints", e))?;
992        self.run_tool_with_timeout(
993            "list_checkpoints",
994            tools::fork::list_checkpoints(self.state.clone(), params),
995        )
996        .await
997        .map(json)
998        .map_err(|e| to_mcp_error_for_tool("list_checkpoints", e))
999    }
1000
1001    #[tool(
1002        name = "restore_checkpoint",
1003        description = "Restore a fork to a checkpoint"
1004    )]
1005    pub async fn restore_checkpoint(
1006        &self,
1007        Parameters(params): Parameters<tools::fork::RestoreCheckpointParams>,
1008    ) -> Result<Json<tools::fork::RestoreCheckpointResponse>, McpError> {
1009        self.ensure_recalc_enabled("restore_checkpoint")
1010            .map_err(|e| to_mcp_error_for_tool("restore_checkpoint", e))?;
1011        self.run_tool_with_timeout(
1012            "restore_checkpoint",
1013            tools::fork::restore_checkpoint(self.state.clone(), params),
1014        )
1015        .await
1016        .map(json)
1017        .map_err(|e| to_mcp_error_for_tool("restore_checkpoint", e))
1018    }
1019
1020    #[tool(
1021        name = "delete_checkpoint",
1022        description = "Delete a checkpoint from a fork"
1023    )]
1024    pub async fn delete_checkpoint(
1025        &self,
1026        Parameters(params): Parameters<tools::fork::DeleteCheckpointParams>,
1027    ) -> Result<Json<tools::fork::DeleteCheckpointResponse>, McpError> {
1028        self.ensure_recalc_enabled("delete_checkpoint")
1029            .map_err(|e| to_mcp_error_for_tool("delete_checkpoint", e))?;
1030        self.run_tool_with_timeout(
1031            "delete_checkpoint",
1032            tools::fork::delete_checkpoint(self.state.clone(), params),
1033        )
1034        .await
1035        .map(json)
1036        .map_err(|e| to_mcp_error_for_tool("delete_checkpoint", e))
1037    }
1038
1039    #[tool(
1040        name = "list_staged_changes",
1041        description = "List previewed/staged changes for a fork"
1042    )]
1043    pub async fn list_staged_changes(
1044        &self,
1045        Parameters(params): Parameters<tools::fork::ListStagedChangesParams>,
1046    ) -> Result<Json<tools::fork::ListStagedChangesResponse>, McpError> {
1047        self.ensure_recalc_enabled("list_staged_changes")
1048            .map_err(|e| to_mcp_error_for_tool("list_staged_changes", e))?;
1049        self.run_tool_with_timeout(
1050            "list_staged_changes",
1051            tools::fork::list_staged_changes(self.state.clone(), params),
1052        )
1053        .await
1054        .map(json)
1055        .map_err(|e| to_mcp_error_for_tool("list_staged_changes", e))
1056    }
1057
1058    #[tool(
1059        name = "apply_staged_change",
1060        description = "Apply a staged change to a fork"
1061    )]
1062    pub async fn apply_staged_change(
1063        &self,
1064        Parameters(params): Parameters<tools::fork::ApplyStagedChangeParams>,
1065    ) -> Result<Json<tools::fork::ApplyStagedChangeResponse>, McpError> {
1066        self.ensure_recalc_enabled("apply_staged_change")
1067            .map_err(|e| to_mcp_error_for_tool("apply_staged_change", e))?;
1068        self.run_tool_with_timeout(
1069            "apply_staged_change",
1070            tools::fork::apply_staged_change(self.state.clone(), params),
1071        )
1072        .await
1073        .map(json)
1074        .map_err(|e| to_mcp_error_for_tool("apply_staged_change", e))
1075    }
1076
1077    #[tool(
1078        name = "discard_staged_change",
1079        description = "Discard a staged change without applying it"
1080    )]
1081    pub async fn discard_staged_change(
1082        &self,
1083        Parameters(params): Parameters<tools::fork::DiscardStagedChangeParams>,
1084    ) -> Result<Json<tools::fork::DiscardStagedChangeResponse>, McpError> {
1085        self.ensure_recalc_enabled("discard_staged_change")
1086            .map_err(|e| to_mcp_error_for_tool("discard_staged_change", e))?;
1087        self.run_tool_with_timeout(
1088            "discard_staged_change",
1089            tools::fork::discard_staged_change(self.state.clone(), params),
1090        )
1091        .await
1092        .map(json)
1093        .map_err(|e| to_mcp_error_for_tool("discard_staged_change", e))
1094    }
1095
1096    #[tool(
1097        name = "screenshot_sheet",
1098        description = "Capture a visual screenshot of a spreadsheet region as PNG. \
1099	Returns file URI. Max range: 100 rows x 30 columns. Default: A1:M40."
1100    )]
1101    pub async fn screenshot_sheet(
1102        &self,
1103        Parameters(params): Parameters<tools::fork::ScreenshotSheetParams>,
1104    ) -> Result<rmcp::model::CallToolResult, McpError> {
1105        use base64::Engine;
1106        use rmcp::model::Content;
1107
1108        self.ensure_recalc_enabled("screenshot_sheet")
1109            .map_err(|e| to_mcp_error_for_tool("screenshot_sheet", e))?;
1110
1111        let result = async {
1112            let response = self
1113                .run_tool_with_timeout(
1114                    "screenshot_sheet",
1115                    tools::fork::screenshot_sheet(self.state.clone(), params),
1116                )
1117                .await?;
1118
1119            let mut content = Vec::new();
1120
1121            let fs_path = response
1122                .output_path
1123                .strip_prefix("file://")
1124                .ok_or_else(|| anyhow!("unexpected screenshot output_path"))?;
1125            let bytes = tokio::fs::read(fs_path)
1126                .await
1127                .map_err(|e| anyhow!("failed to read screenshot: {}", e))?;
1128
1129            if let Some(limit) = self.state.config().max_response_bytes() {
1130                let encoded_len = bytes.len().div_ceil(3) * 4;
1131                let meta = serde_json::to_vec(&response)
1132                    .map_err(|e| anyhow!("failed to serialize response: {}", e))?;
1133                let estimated = encoded_len + meta.len() + response.output_path.len();
1134                if estimated > limit {
1135                    return Err(
1136                        ResponseTooLargeError::new("screenshot_sheet", estimated, limit).into(),
1137                    );
1138                }
1139            }
1140
1141            let data = base64::engine::general_purpose::STANDARD.encode(bytes);
1142            content.push(Content::image(data, "image/png"));
1143
1144            // Always include a small text hint for clients that ignore structured_content.
1145            content.push(Content::text(response.output_path.clone()));
1146
1147            let structured_content = to_pruned_value(&response)
1148                .map_err(|e| anyhow!("failed to serialize response: {}", e))?;
1149
1150            Ok(rmcp::model::CallToolResult {
1151                content,
1152                structured_content: Some(structured_content),
1153                is_error: Some(false),
1154                meta: None,
1155            })
1156        }
1157        .await;
1158
1159        result.map_err(|e| to_mcp_error_for_tool("screenshot_sheet", e))
1160    }
1161}
1162
1163#[tool_handler(router = self.tool_router)]
1164impl ServerHandler for SpreadsheetServer {
1165    fn get_info(&self) -> ServerInfo {
1166        let recalc_enabled = {
1167            #[cfg(feature = "recalc")]
1168            {
1169                self.state.config().recalc_enabled
1170            }
1171            #[cfg(not(feature = "recalc"))]
1172            {
1173                false
1174            }
1175        };
1176
1177        let vba_enabled = self.state.config().vba_enabled;
1178
1179        ServerInfo {
1180            capabilities: ServerCapabilities::builder().enable_tools().build(),
1181            server_info: Implementation::from_build_env(),
1182            instructions: Some(build_instructions(recalc_enabled, vba_enabled)),
1183            ..ServerInfo::default()
1184        }
1185    }
1186}
1187
1188fn to_mcp_error_for_tool(tool: &str, error: anyhow::Error) -> McpError {
1189    if error.is::<ToolDisabledError>() || error.is::<ResponseTooLargeError>() {
1190        return McpError::invalid_request(error.to_string(), None);
1191    }
1192
1193    if let Some(inv) = error.downcast_ref::<InvalidParamsError>() {
1194        let example = tool_minimal_example(tool);
1195        let variants = tool_variants(tool, inv.message())
1196            .unwrap_or_default()
1197            .into_iter()
1198            .map(|s| s.to_string())
1199            .collect::<Vec<_>>();
1200        let msg = format_invalid_params_message(
1201            tool,
1202            inv.message(),
1203            inv.path(),
1204            if variants.is_empty() {
1205                None
1206            } else {
1207                Some(&variants)
1208            },
1209            example,
1210        );
1211        return McpError::invalid_params(msg, None);
1212    }
1213
1214    if let Some(serde_err) = error.downcast_ref::<serde_json::Error>() {
1215        let problem = serde_err.to_string();
1216        let path = infer_path_for_tool(tool, &problem);
1217
1218        let mut variants = extract_expected_variants(&problem);
1219        if variants.is_empty()
1220            && let Some(extra) = tool_variants(tool, &problem)
1221        {
1222            variants = extra.into_iter().map(|s| s.to_string()).collect();
1223        }
1224
1225        let example = tool_minimal_example(tool);
1226        let msg = format_invalid_params_message(
1227            tool,
1228            &problem,
1229            path.as_deref(),
1230            if variants.is_empty() {
1231                None
1232            } else {
1233                Some(&variants)
1234            },
1235            example,
1236        );
1237        return McpError::invalid_params(msg, None);
1238    }
1239
1240    // Heuristic fallbacks for common user-caused shape/enum mistakes that may not
1241    // be typed as serde_json::Error (e.g., anyhow::bail! paths).
1242    let problem = error.to_string();
1243    if looks_like_invalid_params(&problem) {
1244        let path = infer_path_for_tool(tool, &problem);
1245        let variants = tool_variants(tool, &problem)
1246            .unwrap_or_default()
1247            .into_iter()
1248            .map(|s| s.to_string())
1249            .collect::<Vec<_>>();
1250        let example = tool_minimal_example(tool);
1251        let msg = format_invalid_params_message(
1252            tool,
1253            &problem,
1254            path.as_deref(),
1255            if variants.is_empty() {
1256                None
1257            } else {
1258                Some(&variants)
1259            },
1260            example,
1261        );
1262        return McpError::invalid_params(msg, None);
1263    }
1264
1265    McpError::internal_error(problem, None)
1266}
1267
1268fn format_invalid_params_message(
1269    tool: &str,
1270    problem: &str,
1271    path: Option<&str>,
1272    variants: Option<&[String]>,
1273    example: Option<&'static str>,
1274) -> String {
1275    let mut out = String::new();
1276    out.push_str(&format!("Invalid params for tool '{tool}': {problem}"));
1277
1278    if let Some(path) = path {
1279        out.push_str(&format!("\npath: {path}"));
1280    }
1281
1282    if let Some(variants) = variants
1283        && !variants.is_empty()
1284    {
1285        out.push_str("\nvalid variants: ");
1286        out.push_str(&variants.join(", "));
1287    }
1288
1289    if let Some(example) = example {
1290        out.push_str("\nexample: ");
1291        out.push_str(example);
1292    }
1293
1294    out
1295}
1296
1297fn tool_minimal_example(tool: &str) -> Option<&'static str> {
1298    match tool {
1299        "structure_batch" => Some(
1300            r#"{"fork_id":"<fork_id>","ops":[{"kind":"insert_rows","sheet_name":"Sheet1","at_row":2,"count":1}],"mode":"apply"}"#,
1301        ),
1302        "style_batch" => Some(
1303            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"}"#,
1304        ),
1305        "edit_batch" => Some(
1306            r#"{"fork_id":"<fork_id>","sheet_name":"Sheet1","edits":["A1=100","B2==SUM(A1:A2)"]}"#,
1307        ),
1308        "sheet_layout_batch" => Some(
1309            r#"{"fork_id":"<fork_id>","ops":[{"kind":"freeze_panes","sheet_name":"Dashboard","freeze_rows":1,"freeze_cols":1}],"mode":"apply"}"#,
1310        ),
1311        "rules_batch" => Some(
1312            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"}"#,
1313        ),
1314        _ => None,
1315    }
1316}
1317
1318fn infer_path_for_tool(tool: &str, problem: &str) -> Option<String> {
1319    let p = problem.to_ascii_lowercase();
1320
1321    match tool {
1322        "structure_batch" => {
1323            if p.contains("structure op") && (p.contains("kind") || p.contains("op")) {
1324                return Some("ops[0].kind".to_string());
1325            }
1326            if p.contains("missing field `kind`") || p.contains("missing field kind") {
1327                return Some("ops[0].kind".to_string());
1328            }
1329            None
1330        }
1331        "style_batch" => {
1332            if p.contains("fillpatch") || p.contains("fillpatchinput") {
1333                return Some("ops[0].patch.fill.kind".to_string());
1334            }
1335            if p.contains("styletarget") && p.contains("kind") {
1336                return Some("ops[0].target.kind".to_string());
1337            }
1338            None
1339        }
1340        "sheet_layout_batch" => {
1341            if p.contains("missing field `kind`") || p.contains("missing field kind") {
1342                return Some("ops[0].kind".to_string());
1343            }
1344            if p.contains("sheetlayoutop") && p.contains("kind") {
1345                return Some("ops[0].kind".to_string());
1346            }
1347            if p.contains("unknown variant") && p.contains("apply") && p.contains("preview") {
1348                return Some("mode".to_string());
1349            }
1350            if p.contains("mode") && p.contains("invalid") {
1351                return Some("mode".to_string());
1352            }
1353            None
1354        }
1355        "rules_batch" => {
1356            if p.contains("missing field `kind`") || p.contains("missing field kind") {
1357                return Some("ops[0].kind".to_string());
1358            }
1359            if p.contains("rulesop") && p.contains("kind") {
1360                return Some("ops[0].kind".to_string());
1361            }
1362            if p.contains("datavalidationkind") {
1363                return Some("ops[0].validation.kind".to_string());
1364            }
1365            if p.contains("conditionalformat") && p.contains("operator") {
1366                return Some("ops[0].rule.operator".to_string());
1367            }
1368            if p.contains("conditionalformatrulespec") && p.contains("kind") {
1369                return Some("ops[0].rule.kind".to_string());
1370            }
1371            if p.contains("unknown variant") && p.contains("apply") && p.contains("preview") {
1372                return Some("mode".to_string());
1373            }
1374            if p.contains("mode") && p.contains("invalid") {
1375                return Some("mode".to_string());
1376            }
1377            None
1378        }
1379        _ => None,
1380    }
1381}
1382
1383fn tool_variants(tool: &str, problem: &str) -> Option<Vec<&'static str>> {
1384    let p = problem.to_ascii_lowercase();
1385
1386    match tool {
1387        "structure_batch" => {
1388            if p.contains("structure op")
1389                || p.contains("structureop")
1390                || (p.contains("unknown variant") && p.contains("kind"))
1391            {
1392                return Some(vec![
1393                    "insert_rows",
1394                    "delete_rows",
1395                    "insert_cols",
1396                    "delete_cols",
1397                    "rename_sheet",
1398                    "create_sheet",
1399                    "delete_sheet",
1400                    "copy_range",
1401                    "move_range",
1402                ]);
1403            }
1404            None
1405        }
1406        "style_batch" => {
1407            if p.contains("fill") || p.contains("fillpatch") || p.contains("fillpatchinput") {
1408                return Some(vec!["pattern", "gradient"]);
1409            }
1410            if p.contains("op_mode") || p.contains("op mode") {
1411                return Some(vec!["merge", "set", "clear"]);
1412            }
1413            None
1414        }
1415        "sheet_layout_batch" => {
1416            if p.contains("sheetlayoutop")
1417                || p.contains("sheet layout op")
1418                || (p.contains("unknown variant") && p.contains("kind"))
1419                || p.contains("missing field `kind`")
1420                || p.contains("missing field kind")
1421            {
1422                return Some(vec![
1423                    "freeze_panes",
1424                    "set_zoom",
1425                    "set_gridlines",
1426                    "set_page_margins",
1427                    "set_page_setup",
1428                    "set_print_area",
1429                    "set_page_breaks",
1430                ]);
1431            }
1432            None
1433        }
1434        "rules_batch" => {
1435            if p.contains("rulesop")
1436                || p.contains("rules op")
1437                || (p.contains("unknown variant") && p.contains("kind"))
1438                || p.contains("missing field `kind`")
1439                || p.contains("missing field kind")
1440            {
1441                return Some(vec![
1442                    "set_data_validation",
1443                    "add_conditional_format",
1444                    "set_conditional_format",
1445                    "clear_conditional_formats",
1446                ]);
1447            }
1448
1449            if p.contains("datavalidationkind") {
1450                return Some(vec!["list", "whole", "decimal", "date", "custom"]);
1451            }
1452            if p.contains("conditionalformatrulespec") {
1453                return Some(vec!["cell_is", "expression"]);
1454            }
1455            if p.contains("conditionalformatoperator") {
1456                return Some(vec![
1457                    "less_than",
1458                    "less_than_or_equal",
1459                    "greater_than",
1460                    "greater_than_or_equal",
1461                    "equal",
1462                    "not_equal",
1463                    "between",
1464                    "not_between",
1465                ]);
1466            }
1467            None
1468        }
1469        _ => None,
1470    }
1471}
1472
1473fn looks_like_invalid_params(problem: &str) -> bool {
1474    let p = problem.to_ascii_lowercase();
1475
1476    // serde-driven shape/enum failures
1477    if p.contains("missing field")
1478        || p.contains("unknown field")
1479        || p.contains("unknown variant")
1480        || p.contains("did not match any variant")
1481        || p.contains("must be an object")
1482    {
1483        return true;
1484    }
1485
1486    // common hand-rolled validation errors
1487    if p.contains("invalid shorthand edit") {
1488        return true;
1489    }
1490
1491    if p.contains("invalid mode") {
1492        return true;
1493    }
1494
1495    false
1496}
1497
1498fn extract_expected_variants(problem: &str) -> Vec<String> {
1499    static EXPECTED_TAIL_RE: Lazy<Regex> =
1500        Lazy::new(|| Regex::new(r"expected(?: one of)? (?P<tail>.*)$").expect("regex"));
1501    static BACKTICK_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"`([^`]+)`").expect("regex"));
1502
1503    let Some(caps) = EXPECTED_TAIL_RE.captures(problem) else {
1504        return Vec::new();
1505    };
1506    let tail = caps.name("tail").map(|m| m.as_str()).unwrap_or("");
1507    BACKTICK_RE
1508        .captures_iter(tail)
1509        .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
1510        .collect()
1511}
1512
1513#[cfg(all(test, feature = "recalc"))]
1514mod typed_errors_tests {
1515    use super::to_mcp_error_for_tool;
1516    use crate::tools;
1517    use rmcp::model::ErrorCode;
1518    use serde_json::json;
1519
1520    #[test]
1521    fn structure_batch_missing_kind_or_op_is_invalid_params_with_example_and_variants() {
1522        let bad = json!({
1523            "fork_id": "f1",
1524            "ops": [
1525                { "sheet_name": "Sheet1", "at_row": 2, "count": 1 }
1526            ]
1527        });
1528
1529        let err =
1530            serde_json::from_value::<tools::fork::StructureBatchParamsInput>(bad).unwrap_err();
1531        let mcp = to_mcp_error_for_tool("structure_batch", err.into());
1532
1533        assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
1534        assert!(mcp.message.to_ascii_lowercase().contains("example:"));
1535        assert!(mcp.message.contains("insert_rows"));
1536        assert!(mcp.message.to_ascii_lowercase().contains("valid variants"));
1537    }
1538
1539    #[test]
1540    fn style_batch_fill_missing_kind_is_invalid_params_with_example_and_variants() {
1541        let bad = json!({
1542            "fork_id": "f1",
1543            "ops": [
1544                {
1545                    "sheet_name": "Sheet1",
1546                    "target": { "kind": "range", "range": "A1:A1" },
1547                    "patch": {
1548                        "fill": { "pattern_type": "solid", "foreground_color": "FFFF0000" }
1549                    }
1550                }
1551            ]
1552        });
1553
1554        let err = serde_json::from_value::<tools::fork::StyleBatchParamsInput>(bad).unwrap_err();
1555        let mcp = to_mcp_error_for_tool("style_batch", err.into());
1556
1557        assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
1558        assert!(mcp.message.to_ascii_lowercase().contains("example:"));
1559        assert!(mcp.message.contains("pattern"));
1560        assert!(mcp.message.to_ascii_lowercase().contains("valid variants"));
1561    }
1562
1563    #[test]
1564    fn edit_batch_shorthand_missing_equals_is_invalid_params_with_example() {
1565        let params = tools::write_normalize::EditBatchParamsInput {
1566            fork_id: "f1".to_string(),
1567            sheet_name: "Sheet1".to_string(),
1568            edits: vec![tools::write_normalize::CellEditInput::Shorthand(
1569                "A1".to_string(),
1570            )],
1571        };
1572
1573        let err = tools::write_normalize::normalize_edit_batch(params).unwrap_err();
1574        let mcp = to_mcp_error_for_tool("edit_batch", err);
1575
1576        assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
1577        assert!(mcp.message.to_ascii_lowercase().contains("example:"));
1578        assert!(mcp.message.contains("A1=100"));
1579    }
1580
1581    #[test]
1582    fn sheet_layout_batch_missing_kind_is_invalid_params_with_example_and_variants() {
1583        let bad = json!({
1584            "fork_id": "f1",
1585            "ops": [
1586                { "sheet_name": "Dashboard", "freeze_rows": 1, "freeze_cols": 1 }
1587            ],
1588            "mode": "apply"
1589        });
1590
1591        let err =
1592            serde_json::from_value::<tools::sheet_layout::SheetLayoutBatchParams>(bad).unwrap_err();
1593        let mcp = to_mcp_error_for_tool("sheet_layout_batch", err.into());
1594
1595        assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
1596        assert!(mcp.message.to_ascii_lowercase().contains("example:"));
1597        assert!(mcp.message.to_ascii_lowercase().contains("valid variants"));
1598        assert!(mcp.message.contains("freeze_panes"));
1599    }
1600
1601    #[test]
1602    fn rules_batch_missing_kind_is_invalid_params_with_example_and_variants() {
1603        let bad = json!({
1604            "fork_id": "f1",
1605            "ops": [
1606                {
1607                    "sheet_name": "Inputs",
1608                    "target_range": "B3:B10",
1609                    "validation": { "kind": "list", "formula1": "=Lists!$A$1:$A$10" }
1610                }
1611            ],
1612            "mode": "apply"
1613        });
1614
1615        let err = serde_json::from_value::<tools::rules_batch::RulesBatchParams>(bad).unwrap_err();
1616        let mcp = to_mcp_error_for_tool("rules_batch", err.into());
1617
1618        assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
1619        assert!(mcp.message.to_ascii_lowercase().contains("example:"));
1620        assert!(mcp.message.to_ascii_lowercase().contains("valid variants"));
1621        assert!(mcp.message.contains("set_data_validation"));
1622    }
1623
1624    #[test]
1625    fn rules_batch_invalid_mode_is_invalid_params_with_example_and_path() {
1626        let bad = json!({
1627            "fork_id": "f1",
1628            "ops": [
1629                {
1630                    "kind": "set_data_validation",
1631                    "sheet_name": "Inputs",
1632                    "target_range": "B3:B10",
1633                    "validation": { "kind": "list", "formula1": "=Lists!$A$1:$A$10" }
1634                }
1635            ],
1636            "mode": "maybe"
1637        });
1638
1639        let err = serde_json::from_value::<tools::rules_batch::RulesBatchParams>(bad).unwrap_err();
1640        let mcp = to_mcp_error_for_tool("rules_batch", err.into());
1641
1642        assert_eq!(mcp.code, ErrorCode::INVALID_PARAMS);
1643        assert!(mcp.message.to_ascii_lowercase().contains("example:"));
1644        assert!(mcp.message.to_ascii_lowercase().contains("path: mode"));
1645    }
1646}
1647
1648#[derive(Debug, Error)]
1649#[error("tool '{tool_name}' is disabled by server configuration")]
1650struct ToolDisabledError {
1651    tool_name: String,
1652}
1653
1654impl ToolDisabledError {
1655    fn new(tool_name: &str) -> Self {
1656        Self {
1657            tool_name: tool_name.to_ascii_lowercase(),
1658        }
1659    }
1660}
1661
1662#[derive(Debug, Error)]
1663#[error(
1664    "tool '{tool_name}' response too large ({size} bytes > {limit} bytes); reduce request size or page results"
1665)]
1666struct ResponseTooLargeError {
1667    tool_name: String,
1668    size: usize,
1669    limit: usize,
1670}
1671
1672impl ResponseTooLargeError {
1673    fn new(tool_name: &str, size: usize, limit: usize) -> Self {
1674        Self {
1675            tool_name: tool_name.to_ascii_lowercase(),
1676            size,
1677            limit,
1678        }
1679    }
1680}
1681
1682#[derive(Debug, Error)]
1683#[error("VBA tools are disabled (set SPREADSHEET_MCP_VBA_ENABLED=true)")]
1684struct VbaDisabledError;
1685
1686#[cfg(feature = "recalc")]
1687#[derive(Debug, Error)]
1688#[error("recalc/write tools are disabled (set SPREADSHEET_MCP_RECALC_ENABLED=true)")]
1689struct RecalcDisabledError;