Skip to main content

spreadsheet_mcp/
server.rs

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