1use crate::config::ServerConfig;
2use crate::model::{
3 CloseWorkbookResponse, FindFormulaResponse, FindValueResponse, FormulaTraceResponse,
4 ManifestStubResponse, NamedRangesResponse, RangeValuesResponse, ReadTableResponse,
5 SheetFormulaMapResponse, SheetListResponse, SheetOverviewResponse, SheetPageResponse,
6 SheetStatisticsResponse, SheetStylesResponse, TableProfileResponse, VolatileScanResponse,
7 WorkbookDescription, WorkbookListResponse, WorkbookStyleSummaryResponse,
8 WorkbookSummaryResponse,
9};
10use crate::state::AppState;
11use crate::tools;
12use anyhow::{Result, anyhow};
13use rmcp::{
14 ErrorData as McpError, Json, ServerHandler, ServiceExt,
15 handler::server::{router::tool::ToolRouter, wrapper::Parameters},
16 model::{Implementation, ServerCapabilities, ServerInfo},
17 tool, tool_handler, tool_router,
18 transport::stdio,
19};
20use serde::Serialize;
21use std::future::Future;
22use std::sync::Arc;
23use thiserror::Error;
24
25const BASE_INSTRUCTIONS: &str = "\
26Spreadsheet MCP: optimized for spreadsheet analysis.
27
28WORKFLOW:
291) list_workbooks → list_sheets → workbook_summary for orientation
302) sheet_overview for region detection (ids/bounds/kind/confidence)
313) For structured data: table_profile for quick column sense, then read_table with region_id/range, filters, sampling
324) For spot checks: range_values or find_value (label mode for key-value sheets)
33
34TOOL SELECTION:
35- table_profile: Fast column/type summary before wide reads.
36- read_table: Structured table extraction. Prefer region_id or tight range; use limit + sample_mode.
37- sheet_formula_map: Get formula overview. Use limit param for large sheets (e.g., limit=10). \
38Use sort_by='complexity' for most complex formulas first, or 'count' for most repeated. \
39Use range param to scope to specific region.
40- formula_trace: Trace ONE cell's precedents/dependents. Use AFTER formula_map \
41to dive deep on specific outputs (e.g., trace the total cell to understand calc flow).
42- sheet_page: Raw cell dump. Use ONLY when region detection fails or for \
43unstructured sheets. Prefer read_table for tabular data.
44- find_value with mode='label': For key-value layouts (label in col A, value in col B). \
45Use direction='right' or 'below' hints.
46- find_formula: Search formulas. Default returns no context and only first 50 matches. \
47Use include_context=true for header+cell snapshots, and use limit/offset to page.
48
49RANGES: Use A1 notation (e.g., A1:C10). Prefer region_id when available.
50
51DATES: Cells with date formats return ISO-8601 strings (YYYY-MM-DD).
52
53Keep payloads small. Page through large sheets.";
54
55const VBA_INSTRUCTIONS: &str = "
56
57VBA TOOLS (enabled):
58Read-only VBA project inspection for .xlsm workbooks.
59
60WORKFLOW:
611) list_workbooks → describe_workbook to find candidate .xlsm
622) vba_project_summary to list modules
633) vba_module_source to page module code
64
65TOOLS:
66- vba_project_summary: Parse and summarize the embedded vbaProject.bin (modules + metadata).
67- vba_module_source: Return paged source for one module (use offset_lines/limit_lines).
68
69SAFETY:
70- Treat VBA as untrusted code. Tools only read and return text.
71- Responses are size-limited; page through module source.
72";
73
74const WRITE_INSTRUCTIONS: &str = "
75
76WRITE/RECALC TOOLS (enabled):
77Fork-based editing allows 'what-if' analysis without modifying original files.
78
79WORKFLOW:
801) create_fork: Create editable copy of a workbook. Returns fork_id.
812) Optional: checkpoint_fork before large edits.
823) edit_batch/transform_batch/style_batch/structure_batch/apply_formula_pattern: Apply edits to the fork.
834) recalculate: Trigger LibreOffice to recompute all formulas.
845) get_changeset: Diff fork against original. Use filters/limit/offset to keep it small.
85 Optional: screenshot_sheet to capture a visual view of a range (original or fork).
866) save_fork: Write changes to file.
877) discard_fork: Delete fork without saving.
88
89SAFETY:
90- checkpoint_fork before large/structural edits; restore_checkpoint to rollback if needed.
91- 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.
92
93TOOL DETAILS:
94- create_fork: Only .xlsx supported. Returns fork_id for subsequent operations.
95- edit_batch: {fork_id, sheet_name, edits:[{address, value, is_formula}]}. \
96Formulas should NOT include leading '='.
97- transform_batch: Range-first clear/fill/replace. Prefer for bulk edits (blank/fill/rename) to avoid per-cell edit_batch bloat.
98- recalculate: Required after edit_batch to update formula results. \
99May take several seconds for complex workbooks.
100- get_changeset: Returns a paged diff + summary. Use limit/offset to page. \
101Use include_types/exclude_types/include_subtypes/exclude_subtypes to filter (e.g. exclude_subtypes=['recalc_result']). \
102Use summary_only=true when you only need counts.
103- screenshot_sheet: {workbook_or_fork_id, sheet_name, range?}. Renders a cropped PNG for inspecting an area visually.
104 workbook_or_fork_id may be either a real workbook_id OR a fork_id (to screenshot an edited fork).
105 Returns a file:// URI under workspace_root/screenshots/ (Docker default: /data/screenshots/).
106 DO NOT call save_fork just to get a screenshot.
107 If formulas changed, run recalculate on the fork first.
108- save_fork: Requires target_path for new file location.
109 If target_path is relative, it is resolved under workspace_root (Docker default: `/data`).
110 Overwriting original requires server --allow-overwrite flag.
111 Use drop_fork=false to keep fork active after saving (default: true drops fork).
112 Validates base file unchanged since fork creation.
113- get_edits: List all edits applied to a fork (before recalculate).
114- list_forks: See all active forks.
115- checkpoint_fork: Snapshot a fork to a checkpoint for high-fidelity undo.
116- list_checkpoints: List checkpoints for a fork.
117- restore_checkpoint: Restore a fork to a checkpoint (overwrites fork file; clears newer staged changes).
118- delete_checkpoint: Delete a checkpoint.
119- list_staged_changes: List staged (previewed) changes for a fork.
120- apply_staged_change: Apply a staged change to the fork.
121- discard_staged_change: Discard a staged change.
122
123BEST PRACTICES:
124- Always recalculate after edit_batch before get_changeset.
125- Review changeset before save_fork to verify expected changes.
126- Use screenshot_sheet for quick visual inspection; save_fork is ONLY for exporting a workbook file.
127- Discard forks when done to free resources (auto-cleanup after 1 hour).
128- For large edits, batch multiple cells in single edit_batch call.";
129
130fn build_instructions(recalc_enabled: bool, vba_enabled: bool) -> String {
131 let mut instructions = BASE_INSTRUCTIONS.to_string();
132
133 if vba_enabled {
134 instructions.push_str(VBA_INSTRUCTIONS);
135 } else {
136 instructions
137 .push_str("\n\nVBA tools disabled. Set SPREADSHEET_MCP_VBA_ENABLED=true to enable.");
138 }
139
140 if recalc_enabled {
141 instructions.push_str(WRITE_INSTRUCTIONS);
142 } else {
143 instructions.push_str("\n\nRead-only mode. Write/recalc tools disabled.");
144 }
145 instructions
146}
147
148#[derive(Clone)]
149pub struct SpreadsheetServer {
150 state: Arc<AppState>,
151 tool_router: ToolRouter<SpreadsheetServer>,
152}
153
154impl SpreadsheetServer {
155 pub async fn new(config: Arc<ServerConfig>) -> Result<Self> {
156 config.ensure_workspace_root()?;
157 let state = Arc::new(AppState::new(config));
158 Ok(Self::from_state(state))
159 }
160
161 pub fn from_state(state: Arc<AppState>) -> Self {
162 #[allow(unused_mut)]
163 let mut router = Self::tool_router();
164
165 #[cfg(feature = "recalc")]
166 {
167 router.merge(Self::fork_tool_router());
168 }
169
170 if state.config().vba_enabled {
171 router.merge(Self::vba_tool_router());
172 }
173
174 Self {
175 state,
176 tool_router: router,
177 }
178 }
179
180 pub async fn run_stdio(self) -> Result<()> {
181 let service = self
182 .serve(stdio())
183 .await
184 .inspect_err(|error| tracing::error!("serving error: {:?}", error))?;
185 service.waiting().await?;
186 Ok(())
187 }
188
189 pub async fn run(self) -> Result<()> {
190 self.run_stdio().await
191 }
192
193 fn ensure_tool_enabled(&self, tool: &str) -> Result<()> {
194 tracing::info!(tool = tool, "tool invocation requested");
195 if self.state.config().is_tool_enabled(tool) {
196 Ok(())
197 } else {
198 Err(ToolDisabledError::new(tool).into())
199 }
200 }
201
202 fn ensure_vba_enabled(&self, tool: &str) -> Result<()> {
203 self.ensure_tool_enabled(tool)?;
204 if self.state.config().vba_enabled {
205 Ok(())
206 } else {
207 Err(VbaDisabledError.into())
208 }
209 }
210
211 #[cfg(feature = "recalc")]
212 fn ensure_recalc_enabled(&self, tool: &str) -> Result<()> {
213 self.ensure_tool_enabled(tool)?;
214 if self.state.config().recalc_enabled {
215 Ok(())
216 } else {
217 Err(RecalcDisabledError.into())
218 }
219 }
220
221 async fn run_tool_with_timeout<T, F>(&self, tool: &str, fut: F) -> Result<T>
222 where
223 F: Future<Output = Result<T>>,
224 T: Serialize,
225 {
226 let result = if let Some(timeout_duration) = self.state.config().tool_timeout() {
227 match tokio::time::timeout(timeout_duration, fut).await {
228 Ok(result) => result,
229 Err(_) => Err(anyhow!(
230 "tool '{}' timed out after {}ms",
231 tool,
232 timeout_duration.as_millis()
233 )),
234 }
235 } else {
236 fut.await
237 }?;
238
239 self.ensure_response_size(tool, &result)?;
240 Ok(result)
241 }
242
243 fn ensure_response_size<T: Serialize>(&self, tool: &str, value: &T) -> Result<()> {
244 let Some(limit) = self.state.config().max_response_bytes() else {
245 return Ok(());
246 };
247 let payload = serde_json::to_vec(value)
248 .map_err(|e| anyhow!("failed to serialize response for {}: {}", tool, e))?;
249 if payload.len() > limit {
250 return Err(ResponseTooLargeError::new(tool, payload.len(), limit).into());
251 }
252 Ok(())
253 }
254}
255
256#[tool_router]
257impl SpreadsheetServer {
258 #[tool(
259 name = "list_workbooks",
260 description = "List spreadsheet files in the workspace"
261 )]
262 pub async fn list_workbooks(
263 &self,
264 Parameters(params): Parameters<tools::ListWorkbooksParams>,
265 ) -> Result<Json<WorkbookListResponse>, McpError> {
266 self.ensure_tool_enabled("list_workbooks")
267 .map_err(to_mcp_error)?;
268 self.run_tool_with_timeout(
269 "list_workbooks",
270 tools::list_workbooks(self.state.clone(), params),
271 )
272 .await
273 .map(Json)
274 .map_err(to_mcp_error)
275 }
276
277 #[tool(name = "describe_workbook", description = "Describe workbook metadata")]
278 pub async fn describe_workbook(
279 &self,
280 Parameters(params): Parameters<tools::DescribeWorkbookParams>,
281 ) -> Result<Json<WorkbookDescription>, McpError> {
282 self.ensure_tool_enabled("describe_workbook")
283 .map_err(to_mcp_error)?;
284 self.run_tool_with_timeout(
285 "describe_workbook",
286 tools::describe_workbook(self.state.clone(), params),
287 )
288 .await
289 .map(Json)
290 .map_err(to_mcp_error)
291 }
292
293 #[tool(
294 name = "workbook_summary",
295 description = "Summarize workbook regions and entry points"
296 )]
297 pub async fn workbook_summary(
298 &self,
299 Parameters(params): Parameters<tools::WorkbookSummaryParams>,
300 ) -> Result<Json<WorkbookSummaryResponse>, McpError> {
301 self.ensure_tool_enabled("workbook_summary")
302 .map_err(to_mcp_error)?;
303 self.run_tool_with_timeout(
304 "workbook_summary",
305 tools::workbook_summary(self.state.clone(), params),
306 )
307 .await
308 .map(Json)
309 .map_err(to_mcp_error)
310 }
311
312 #[tool(name = "list_sheets", description = "List sheets with summaries")]
313 pub async fn list_sheets(
314 &self,
315 Parameters(params): Parameters<tools::ListSheetsParams>,
316 ) -> Result<Json<SheetListResponse>, McpError> {
317 self.ensure_tool_enabled("list_sheets")
318 .map_err(to_mcp_error)?;
319 self.run_tool_with_timeout(
320 "list_sheets",
321 tools::list_sheets(self.state.clone(), params),
322 )
323 .await
324 .map(Json)
325 .map_err(to_mcp_error)
326 }
327
328 #[tool(
329 name = "sheet_overview",
330 description = "Get narrative overview for a sheet"
331 )]
332 pub async fn sheet_overview(
333 &self,
334 Parameters(params): Parameters<tools::SheetOverviewParams>,
335 ) -> Result<Json<SheetOverviewResponse>, McpError> {
336 self.ensure_tool_enabled("sheet_overview")
337 .map_err(to_mcp_error)?;
338 self.run_tool_with_timeout(
339 "sheet_overview",
340 tools::sheet_overview(self.state.clone(), params),
341 )
342 .await
343 .map(Json)
344 .map_err(to_mcp_error)
345 }
346
347 #[tool(name = "sheet_page", description = "Page through sheet cells")]
348 pub async fn sheet_page(
349 &self,
350 Parameters(params): Parameters<tools::SheetPageParams>,
351 ) -> Result<Json<SheetPageResponse>, McpError> {
352 self.ensure_tool_enabled("sheet_page")
353 .map_err(to_mcp_error)?;
354 self.run_tool_with_timeout("sheet_page", tools::sheet_page(self.state.clone(), params))
355 .await
356 .map(Json)
357 .map_err(to_mcp_error)
358 }
359
360 #[tool(name = "find_value", description = "Search cell values or labels")]
361 pub async fn find_value(
362 &self,
363 Parameters(params): Parameters<tools::FindValueParams>,
364 ) -> Result<Json<FindValueResponse>, McpError> {
365 self.ensure_tool_enabled("find_value")
366 .map_err(to_mcp_error)?;
367 self.run_tool_with_timeout("find_value", tools::find_value(self.state.clone(), params))
368 .await
369 .map(Json)
370 .map_err(to_mcp_error)
371 }
372
373 #[tool(
374 name = "read_table",
375 description = "Read structured data from a range or table"
376 )]
377 pub async fn read_table(
378 &self,
379 Parameters(params): Parameters<tools::ReadTableParams>,
380 ) -> Result<Json<ReadTableResponse>, McpError> {
381 self.ensure_tool_enabled("read_table")
382 .map_err(to_mcp_error)?;
383 self.run_tool_with_timeout("read_table", tools::read_table(self.state.clone(), params))
384 .await
385 .map(Json)
386 .map_err(to_mcp_error)
387 }
388
389 #[tool(name = "table_profile", description = "Profile a region or table")]
390 pub async fn table_profile(
391 &self,
392 Parameters(params): Parameters<tools::TableProfileParams>,
393 ) -> Result<Json<TableProfileResponse>, McpError> {
394 self.ensure_tool_enabled("table_profile")
395 .map_err(to_mcp_error)?;
396 self.run_tool_with_timeout(
397 "table_profile",
398 tools::table_profile(self.state.clone(), params),
399 )
400 .await
401 .map(Json)
402 .map_err(to_mcp_error)
403 }
404
405 #[tool(
406 name = "range_values",
407 description = "Fetch raw values for specific ranges"
408 )]
409 pub async fn range_values(
410 &self,
411 Parameters(params): Parameters<tools::RangeValuesParams>,
412 ) -> Result<Json<RangeValuesResponse>, McpError> {
413 self.ensure_tool_enabled("range_values")
414 .map_err(to_mcp_error)?;
415 self.run_tool_with_timeout(
416 "range_values",
417 tools::range_values(self.state.clone(), params),
418 )
419 .await
420 .map(Json)
421 .map_err(to_mcp_error)
422 }
423
424 #[tool(
425 name = "sheet_statistics",
426 description = "Get aggregated sheet statistics"
427 )]
428 pub async fn sheet_statistics(
429 &self,
430 Parameters(params): Parameters<tools::SheetStatisticsParams>,
431 ) -> Result<Json<SheetStatisticsResponse>, McpError> {
432 self.ensure_tool_enabled("sheet_statistics")
433 .map_err(to_mcp_error)?;
434 self.run_tool_with_timeout(
435 "sheet_statistics",
436 tools::sheet_statistics(self.state.clone(), params),
437 )
438 .await
439 .map(Json)
440 .map_err(to_mcp_error)
441 }
442
443 #[tool(
444 name = "sheet_formula_map",
445 description = "Summarize formula groups across a sheet"
446 )]
447 pub async fn sheet_formula_map(
448 &self,
449 Parameters(params): Parameters<tools::SheetFormulaMapParams>,
450 ) -> Result<Json<SheetFormulaMapResponse>, McpError> {
451 self.ensure_tool_enabled("sheet_formula_map")
452 .map_err(to_mcp_error)?;
453 self.run_tool_with_timeout(
454 "sheet_formula_map",
455 tools::sheet_formula_map(self.state.clone(), params),
456 )
457 .await
458 .map(Json)
459 .map_err(to_mcp_error)
460 }
461
462 #[tool(
463 name = "formula_trace",
464 description = "Trace formula precedents or dependents"
465 )]
466 pub async fn formula_trace(
467 &self,
468 Parameters(params): Parameters<tools::FormulaTraceParams>,
469 ) -> Result<Json<FormulaTraceResponse>, McpError> {
470 self.ensure_tool_enabled("formula_trace")
471 .map_err(to_mcp_error)?;
472 self.run_tool_with_timeout(
473 "formula_trace",
474 tools::formula_trace(self.state.clone(), params),
475 )
476 .await
477 .map(Json)
478 .map_err(to_mcp_error)
479 }
480
481 #[tool(name = "named_ranges", description = "List named ranges and tables")]
482 pub async fn named_ranges(
483 &self,
484 Parameters(params): Parameters<tools::NamedRangesParams>,
485 ) -> Result<Json<NamedRangesResponse>, McpError> {
486 self.ensure_tool_enabled("named_ranges")
487 .map_err(to_mcp_error)?;
488 self.run_tool_with_timeout(
489 "named_ranges",
490 tools::named_ranges(self.state.clone(), params),
491 )
492 .await
493 .map(Json)
494 .map_err(to_mcp_error)
495 }
496
497 #[tool(
498 name = "find_formula",
499 description = "Search formulas containing text. Defaults: include_context=false, limit=50; use offset for paging."
500 )]
501 pub async fn find_formula(
502 &self,
503 Parameters(params): Parameters<tools::FindFormulaParams>,
504 ) -> Result<Json<FindFormulaResponse>, McpError> {
505 self.ensure_tool_enabled("find_formula")
506 .map_err(to_mcp_error)?;
507 self.run_tool_with_timeout(
508 "find_formula",
509 tools::find_formula(self.state.clone(), params),
510 )
511 .await
512 .map(Json)
513 .map_err(to_mcp_error)
514 }
515
516 #[tool(name = "scan_volatiles", description = "Scan for volatile formulas")]
517 pub async fn scan_volatiles(
518 &self,
519 Parameters(params): Parameters<tools::ScanVolatilesParams>,
520 ) -> Result<Json<VolatileScanResponse>, McpError> {
521 self.ensure_tool_enabled("scan_volatiles")
522 .map_err(to_mcp_error)?;
523 self.run_tool_with_timeout(
524 "scan_volatiles",
525 tools::scan_volatiles(self.state.clone(), params),
526 )
527 .await
528 .map(Json)
529 .map_err(to_mcp_error)
530 }
531
532 #[tool(
533 name = "sheet_styles",
534 description = "Summarise style usage and properties for a sheet"
535 )]
536 pub async fn sheet_styles(
537 &self,
538 Parameters(params): Parameters<tools::SheetStylesParams>,
539 ) -> Result<Json<SheetStylesResponse>, McpError> {
540 self.ensure_tool_enabled("sheet_styles")
541 .map_err(to_mcp_error)?;
542 self.run_tool_with_timeout(
543 "sheet_styles",
544 tools::sheet_styles(self.state.clone(), params),
545 )
546 .await
547 .map(Json)
548 .map_err(to_mcp_error)
549 }
550
551 #[tool(
552 name = "workbook_style_summary",
553 description = "Summarise style usage, theme colors, and conditional formats across a workbook"
554 )]
555 pub async fn workbook_style_summary(
556 &self,
557 Parameters(params): Parameters<tools::WorkbookStyleSummaryParams>,
558 ) -> Result<Json<WorkbookStyleSummaryResponse>, McpError> {
559 self.ensure_tool_enabled("workbook_style_summary")
560 .map_err(to_mcp_error)?;
561 self.run_tool_with_timeout(
562 "workbook_style_summary",
563 tools::workbook_style_summary(self.state.clone(), params),
564 )
565 .await
566 .map(Json)
567 .map_err(to_mcp_error)
568 }
569
570 #[tool(
571 name = "get_manifest_stub",
572 description = "Generate manifest scaffold for workbook"
573 )]
574 pub async fn get_manifest_stub(
575 &self,
576 Parameters(params): Parameters<tools::ManifestStubParams>,
577 ) -> Result<Json<ManifestStubResponse>, McpError> {
578 self.ensure_tool_enabled("get_manifest_stub")
579 .map_err(to_mcp_error)?;
580 self.run_tool_with_timeout(
581 "get_manifest_stub",
582 tools::get_manifest_stub(self.state.clone(), params),
583 )
584 .await
585 .map(Json)
586 .map_err(to_mcp_error)
587 }
588
589 #[tool(name = "close_workbook", description = "Evict a workbook from cache")]
590 pub async fn close_workbook(
591 &self,
592 Parameters(params): Parameters<tools::CloseWorkbookParams>,
593 ) -> Result<Json<CloseWorkbookResponse>, McpError> {
594 self.ensure_tool_enabled("close_workbook")
595 .map_err(to_mcp_error)?;
596 self.run_tool_with_timeout(
597 "close_workbook",
598 tools::close_workbook(self.state.clone(), params),
599 )
600 .await
601 .map(Json)
602 .map_err(to_mcp_error)
603 }
604}
605
606#[tool_router(router = vba_tool_router)]
607impl SpreadsheetServer {
608 #[tool(
609 name = "vba_project_summary",
610 description = "Summarize embedded VBA project (xlsm)"
611 )]
612 pub async fn vba_project_summary(
613 &self,
614 Parameters(params): Parameters<tools::vba::VbaProjectSummaryParams>,
615 ) -> Result<Json<crate::model::VbaProjectSummaryResponse>, McpError> {
616 self.ensure_vba_enabled("vba_project_summary")
617 .map_err(to_mcp_error)?;
618 self.run_tool_with_timeout(
619 "vba_project_summary",
620 tools::vba::vba_project_summary(self.state.clone(), params),
621 )
622 .await
623 .map(Json)
624 .map_err(to_mcp_error)
625 }
626
627 #[tool(
628 name = "vba_module_source",
629 description = "Read VBA module source (paged)"
630 )]
631 pub async fn vba_module_source(
632 &self,
633 Parameters(params): Parameters<tools::vba::VbaModuleSourceParams>,
634 ) -> Result<Json<crate::model::VbaModuleSourceResponse>, McpError> {
635 self.ensure_vba_enabled("vba_module_source")
636 .map_err(to_mcp_error)?;
637 self.run_tool_with_timeout(
638 "vba_module_source",
639 tools::vba::vba_module_source(self.state.clone(), params),
640 )
641 .await
642 .map(Json)
643 .map_err(to_mcp_error)
644 }
645}
646
647#[cfg(feature = "recalc")]
648#[tool_router(router = fork_tool_router)]
649impl SpreadsheetServer {
650 #[tool(
651 name = "create_fork",
652 description = "Create a temporary editable copy of a workbook for what-if analysis"
653 )]
654 pub async fn create_fork(
655 &self,
656 Parameters(params): Parameters<tools::fork::CreateForkParams>,
657 ) -> Result<Json<tools::fork::CreateForkResponse>, McpError> {
658 self.ensure_recalc_enabled("create_fork")
659 .map_err(to_mcp_error)?;
660 self.run_tool_with_timeout(
661 "create_fork",
662 tools::fork::create_fork(self.state.clone(), params),
663 )
664 .await
665 .map(Json)
666 .map_err(to_mcp_error)
667 }
668
669 #[tool(
670 name = "edit_batch",
671 description = "Apply batch edits (values or formulas) to a fork"
672 )]
673 pub async fn edit_batch(
674 &self,
675 Parameters(params): Parameters<tools::fork::EditBatchParams>,
676 ) -> Result<Json<tools::fork::EditBatchResponse>, McpError> {
677 self.ensure_recalc_enabled("edit_batch")
678 .map_err(to_mcp_error)?;
679 self.run_tool_with_timeout(
680 "edit_batch",
681 tools::fork::edit_batch(self.state.clone(), params),
682 )
683 .await
684 .map(Json)
685 .map_err(to_mcp_error)
686 }
687
688 #[tool(
689 name = "transform_batch",
690 description = "Range-oriented transforms for a fork (clear/fill/replace). Supports targets by range, region_id, or explicit cells. \
691Mode: preview or apply (default apply)."
692 )]
693 pub async fn transform_batch(
694 &self,
695 Parameters(params): Parameters<tools::fork::TransformBatchParams>,
696 ) -> Result<Json<tools::fork::TransformBatchResponse>, McpError> {
697 self.ensure_recalc_enabled("transform_batch")
698 .map_err(to_mcp_error)?;
699 self.run_tool_with_timeout(
700 "transform_batch",
701 tools::fork::transform_batch(self.state.clone(), params),
702 )
703 .await
704 .map(Json)
705 .map_err(to_mcp_error)
706 }
707
708 #[tool(
709 name = "style_batch",
710 description = "Apply batch style edits to a fork. Supports targets by range, region_id, or explicit cells. \
711Mode: preview or apply (default apply). Op mode: merge (default), set, or clear."
712 )]
713 pub async fn style_batch(
714 &self,
715 Parameters(params): Parameters<tools::fork::StyleBatchParams>,
716 ) -> Result<Json<tools::fork::StyleBatchResponse>, McpError> {
717 self.ensure_recalc_enabled("style_batch")
718 .map_err(to_mcp_error)?;
719 self.run_tool_with_timeout(
720 "style_batch",
721 tools::fork::style_batch(self.state.clone(), params),
722 )
723 .await
724 .map(Json)
725 .map_err(to_mcp_error)
726 }
727
728 #[tool(
729 name = "apply_formula_pattern",
730 description = "Autofill-like formula pattern application over a target range in a fork. \
731Provide base_formula at anchor_cell, then fill across target_range. \
732Mode: preview or apply (default apply). relative_mode: excel (default), abs_cols, abs_rows. \
733fill_direction: down, right, both (default both)."
734 )]
735 pub async fn apply_formula_pattern(
736 &self,
737 Parameters(params): Parameters<tools::fork::ApplyFormulaPatternParams>,
738 ) -> Result<Json<tools::fork::ApplyFormulaPatternResponse>, McpError> {
739 self.ensure_recalc_enabled("apply_formula_pattern")
740 .map_err(to_mcp_error)?;
741 self.run_tool_with_timeout(
742 "apply_formula_pattern",
743 tools::fork::apply_formula_pattern(self.state.clone(), params),
744 )
745 .await
746 .map(Json)
747 .map_err(to_mcp_error)
748 }
749
750 #[tool(
751 name = "structure_batch",
752 description = "Apply structural edits to a fork (rows/cols/sheets). \
753Mode: preview or apply (default apply). Note: structural edits may not fully rewrite formulas/named ranges like Excel; \
754run recalculate and review get_changeset after applying."
755 )]
756 pub async fn structure_batch(
757 &self,
758 Parameters(params): Parameters<tools::fork::StructureBatchParams>,
759 ) -> Result<Json<tools::fork::StructureBatchResponse>, McpError> {
760 self.ensure_recalc_enabled("structure_batch")
761 .map_err(to_mcp_error)?;
762 self.run_tool_with_timeout(
763 "structure_batch",
764 tools::fork::structure_batch(self.state.clone(), params),
765 )
766 .await
767 .map(Json)
768 .map_err(to_mcp_error)
769 }
770
771 #[tool(name = "get_edits", description = "List all edits applied to a fork")]
772 pub async fn get_edits(
773 &self,
774 Parameters(params): Parameters<tools::fork::GetEditsParams>,
775 ) -> Result<Json<tools::fork::GetEditsResponse>, McpError> {
776 self.ensure_recalc_enabled("get_edits")
777 .map_err(to_mcp_error)?;
778 self.run_tool_with_timeout(
779 "get_edits",
780 tools::fork::get_edits(self.state.clone(), params),
781 )
782 .await
783 .map(Json)
784 .map_err(to_mcp_error)
785 }
786
787 #[tool(
788 name = "get_changeset",
789 description = "Calculate diff between fork and base workbook. Defaults: limit=200. Supports limit/offset paging and type/subtype filters; returns summary."
790 )]
791 pub async fn get_changeset(
792 &self,
793 Parameters(params): Parameters<tools::fork::GetChangesetParams>,
794 ) -> Result<Json<tools::fork::GetChangesetResponse>, McpError> {
795 self.ensure_recalc_enabled("get_changeset")
796 .map_err(to_mcp_error)?;
797 self.run_tool_with_timeout(
798 "get_changeset",
799 tools::fork::get_changeset(self.state.clone(), params),
800 )
801 .await
802 .map(Json)
803 .map_err(to_mcp_error)
804 }
805
806 #[tool(
807 name = "recalculate",
808 description = "Recalculate all formulas in a fork using LibreOffice"
809 )]
810 pub async fn recalculate(
811 &self,
812 Parameters(params): Parameters<tools::fork::RecalculateParams>,
813 ) -> Result<Json<tools::fork::RecalculateResponse>, McpError> {
814 self.ensure_recalc_enabled("recalculate")
815 .map_err(to_mcp_error)?;
816 self.run_tool_with_timeout(
817 "recalculate",
818 tools::fork::recalculate(self.state.clone(), params),
819 )
820 .await
821 .map(Json)
822 .map_err(to_mcp_error)
823 }
824
825 #[tool(name = "list_forks", description = "List all active forks")]
826 pub async fn list_forks(
827 &self,
828 Parameters(params): Parameters<tools::fork::ListForksParams>,
829 ) -> Result<Json<tools::fork::ListForksResponse>, McpError> {
830 self.ensure_recalc_enabled("list_forks")
831 .map_err(to_mcp_error)?;
832 self.run_tool_with_timeout(
833 "list_forks",
834 tools::fork::list_forks(self.state.clone(), params),
835 )
836 .await
837 .map(Json)
838 .map_err(to_mcp_error)
839 }
840
841 #[tool(name = "discard_fork", description = "Discard a fork without saving")]
842 pub async fn discard_fork(
843 &self,
844 Parameters(params): Parameters<tools::fork::DiscardForkParams>,
845 ) -> Result<Json<tools::fork::DiscardForkResponse>, McpError> {
846 self.ensure_recalc_enabled("discard_fork")
847 .map_err(to_mcp_error)?;
848 self.run_tool_with_timeout(
849 "discard_fork",
850 tools::fork::discard_fork(self.state.clone(), params),
851 )
852 .await
853 .map(Json)
854 .map_err(to_mcp_error)
855 }
856
857 #[tool(
858 name = "save_fork",
859 description = "Save fork changes to target path (defaults to overwriting original)"
860 )]
861 pub async fn save_fork(
862 &self,
863 Parameters(params): Parameters<tools::fork::SaveForkParams>,
864 ) -> Result<Json<tools::fork::SaveForkResponse>, McpError> {
865 self.ensure_recalc_enabled("save_fork")
866 .map_err(to_mcp_error)?;
867 self.run_tool_with_timeout(
868 "save_fork",
869 tools::fork::save_fork(self.state.clone(), params),
870 )
871 .await
872 .map(Json)
873 .map_err(to_mcp_error)
874 }
875
876 #[tool(
877 name = "checkpoint_fork",
878 description = "Create a high-fidelity checkpoint snapshot of a fork"
879 )]
880 pub async fn checkpoint_fork(
881 &self,
882 Parameters(params): Parameters<tools::fork::CheckpointForkParams>,
883 ) -> Result<Json<tools::fork::CheckpointForkResponse>, McpError> {
884 self.ensure_recalc_enabled("checkpoint_fork")
885 .map_err(to_mcp_error)?;
886 self.run_tool_with_timeout(
887 "checkpoint_fork",
888 tools::fork::checkpoint_fork(self.state.clone(), params),
889 )
890 .await
891 .map(Json)
892 .map_err(to_mcp_error)
893 }
894
895 #[tool(name = "list_checkpoints", description = "List checkpoints for a fork")]
896 pub async fn list_checkpoints(
897 &self,
898 Parameters(params): Parameters<tools::fork::ListCheckpointsParams>,
899 ) -> Result<Json<tools::fork::ListCheckpointsResponse>, McpError> {
900 self.ensure_recalc_enabled("list_checkpoints")
901 .map_err(to_mcp_error)?;
902 self.run_tool_with_timeout(
903 "list_checkpoints",
904 tools::fork::list_checkpoints(self.state.clone(), params),
905 )
906 .await
907 .map(Json)
908 .map_err(to_mcp_error)
909 }
910
911 #[tool(
912 name = "restore_checkpoint",
913 description = "Restore a fork to a checkpoint"
914 )]
915 pub async fn restore_checkpoint(
916 &self,
917 Parameters(params): Parameters<tools::fork::RestoreCheckpointParams>,
918 ) -> Result<Json<tools::fork::RestoreCheckpointResponse>, McpError> {
919 self.ensure_recalc_enabled("restore_checkpoint")
920 .map_err(to_mcp_error)?;
921 self.run_tool_with_timeout(
922 "restore_checkpoint",
923 tools::fork::restore_checkpoint(self.state.clone(), params),
924 )
925 .await
926 .map(Json)
927 .map_err(to_mcp_error)
928 }
929
930 #[tool(
931 name = "delete_checkpoint",
932 description = "Delete a checkpoint from a fork"
933 )]
934 pub async fn delete_checkpoint(
935 &self,
936 Parameters(params): Parameters<tools::fork::DeleteCheckpointParams>,
937 ) -> Result<Json<tools::fork::DeleteCheckpointResponse>, McpError> {
938 self.ensure_recalc_enabled("delete_checkpoint")
939 .map_err(to_mcp_error)?;
940 self.run_tool_with_timeout(
941 "delete_checkpoint",
942 tools::fork::delete_checkpoint(self.state.clone(), params),
943 )
944 .await
945 .map(Json)
946 .map_err(to_mcp_error)
947 }
948
949 #[tool(
950 name = "list_staged_changes",
951 description = "List previewed/staged changes for a fork"
952 )]
953 pub async fn list_staged_changes(
954 &self,
955 Parameters(params): Parameters<tools::fork::ListStagedChangesParams>,
956 ) -> Result<Json<tools::fork::ListStagedChangesResponse>, McpError> {
957 self.ensure_recalc_enabled("list_staged_changes")
958 .map_err(to_mcp_error)?;
959 self.run_tool_with_timeout(
960 "list_staged_changes",
961 tools::fork::list_staged_changes(self.state.clone(), params),
962 )
963 .await
964 .map(Json)
965 .map_err(to_mcp_error)
966 }
967
968 #[tool(
969 name = "apply_staged_change",
970 description = "Apply a staged change to a fork"
971 )]
972 pub async fn apply_staged_change(
973 &self,
974 Parameters(params): Parameters<tools::fork::ApplyStagedChangeParams>,
975 ) -> Result<Json<tools::fork::ApplyStagedChangeResponse>, McpError> {
976 self.ensure_recalc_enabled("apply_staged_change")
977 .map_err(to_mcp_error)?;
978 self.run_tool_with_timeout(
979 "apply_staged_change",
980 tools::fork::apply_staged_change(self.state.clone(), params),
981 )
982 .await
983 .map(Json)
984 .map_err(to_mcp_error)
985 }
986
987 #[tool(
988 name = "discard_staged_change",
989 description = "Discard a staged change without applying it"
990 )]
991 pub async fn discard_staged_change(
992 &self,
993 Parameters(params): Parameters<tools::fork::DiscardStagedChangeParams>,
994 ) -> Result<Json<tools::fork::DiscardStagedChangeResponse>, McpError> {
995 self.ensure_recalc_enabled("discard_staged_change")
996 .map_err(to_mcp_error)?;
997 self.run_tool_with_timeout(
998 "discard_staged_change",
999 tools::fork::discard_staged_change(self.state.clone(), params),
1000 )
1001 .await
1002 .map(Json)
1003 .map_err(to_mcp_error)
1004 }
1005
1006 #[tool(
1007 name = "screenshot_sheet",
1008 description = "Capture a visual screenshot of a spreadsheet region as PNG. \
1009 Returns file URI. Max range: 100 rows x 30 columns. Default: A1:M40."
1010 )]
1011 pub async fn screenshot_sheet(
1012 &self,
1013 Parameters(params): Parameters<tools::fork::ScreenshotSheetParams>,
1014 ) -> Result<rmcp::model::CallToolResult, McpError> {
1015 use base64::Engine;
1016 use rmcp::model::Content;
1017
1018 self.ensure_recalc_enabled("screenshot_sheet")
1019 .map_err(to_mcp_error)?;
1020
1021 let result = async {
1022 let response = self
1023 .run_tool_with_timeout(
1024 "screenshot_sheet",
1025 tools::fork::screenshot_sheet(self.state.clone(), params),
1026 )
1027 .await?;
1028
1029 let mut content = Vec::new();
1030
1031 let fs_path = response
1032 .output_path
1033 .strip_prefix("file://")
1034 .ok_or_else(|| anyhow!("unexpected screenshot output_path"))?;
1035 let bytes = tokio::fs::read(fs_path)
1036 .await
1037 .map_err(|e| anyhow!("failed to read screenshot: {}", e))?;
1038
1039 if let Some(limit) = self.state.config().max_response_bytes() {
1040 let encoded_len = ((bytes.len() + 2) / 3) * 4;
1041 let meta = serde_json::to_vec(&response)
1042 .map_err(|e| anyhow!("failed to serialize response: {}", e))?;
1043 let estimated = encoded_len + meta.len() + response.output_path.len();
1044 if estimated > limit {
1045 return Err(
1046 ResponseTooLargeError::new("screenshot_sheet", estimated, limit).into(),
1047 );
1048 }
1049 }
1050
1051 let data = base64::engine::general_purpose::STANDARD.encode(bytes);
1052 content.push(Content::image(data, "image/png"));
1053
1054 content.push(Content::text(response.output_path.clone()));
1056
1057 let structured_content = serde_json::to_value(&response)
1058 .map_err(|e| anyhow!("failed to serialize response: {}", e))?;
1059
1060 Ok(rmcp::model::CallToolResult {
1061 content,
1062 structured_content: Some(structured_content),
1063 is_error: Some(false),
1064 meta: None,
1065 })
1066 }
1067 .await;
1068
1069 result.map_err(to_mcp_error)
1070 }
1071}
1072
1073#[tool_handler(router = self.tool_router)]
1074impl ServerHandler for SpreadsheetServer {
1075 fn get_info(&self) -> ServerInfo {
1076 let recalc_enabled = {
1077 #[cfg(feature = "recalc")]
1078 {
1079 self.state.config().recalc_enabled
1080 }
1081 #[cfg(not(feature = "recalc"))]
1082 {
1083 false
1084 }
1085 };
1086
1087 let vba_enabled = self.state.config().vba_enabled;
1088
1089 ServerInfo {
1090 capabilities: ServerCapabilities::builder().enable_tools().build(),
1091 server_info: Implementation::from_build_env(),
1092 instructions: Some(build_instructions(recalc_enabled, vba_enabled)),
1093 ..ServerInfo::default()
1094 }
1095 }
1096}
1097
1098fn to_mcp_error(error: anyhow::Error) -> McpError {
1099 if error.downcast_ref::<ToolDisabledError>().is_some() {
1100 McpError::invalid_request(error.to_string(), None)
1101 } else if error.downcast_ref::<ResponseTooLargeError>().is_some() {
1102 McpError::invalid_request(error.to_string(), None)
1103 } else {
1104 McpError::internal_error(error.to_string(), None)
1105 }
1106}
1107
1108#[derive(Debug, Error)]
1109#[error("tool '{tool_name}' is disabled by server configuration")]
1110struct ToolDisabledError {
1111 tool_name: String,
1112}
1113
1114impl ToolDisabledError {
1115 fn new(tool_name: &str) -> Self {
1116 Self {
1117 tool_name: tool_name.to_ascii_lowercase(),
1118 }
1119 }
1120}
1121
1122#[derive(Debug, Error)]
1123#[error(
1124 "tool '{tool_name}' response too large ({size} bytes > {limit} bytes); reduce request size or page results"
1125)]
1126struct ResponseTooLargeError {
1127 tool_name: String,
1128 size: usize,
1129 limit: usize,
1130}
1131
1132impl ResponseTooLargeError {
1133 fn new(tool_name: &str, size: usize, limit: usize) -> Self {
1134 Self {
1135 tool_name: tool_name.to_ascii_lowercase(),
1136 size,
1137 limit,
1138 }
1139 }
1140}
1141
1142#[derive(Debug, Error)]
1143#[error("VBA tools are disabled (set SPREADSHEET_MCP_VBA_ENABLED=true)")]
1144struct VbaDisabledError;
1145
1146#[cfg(feature = "recalc")]
1147#[derive(Debug, Error)]
1148#[error("recalc/write tools are disabled (set SPREADSHEET_MCP_RECALC_ENABLED=true)")]
1149struct RecalcDisabledError;