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 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 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 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 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;