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