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;
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 std::sync::Arc;
21use thiserror::Error;
22
23const BASE_INSTRUCTIONS: &str = "\
24Spreadsheet MCP: optimized for spreadsheet analysis.
25
26WORKFLOW:
271) list_workbooks → list_sheets → workbook_summary for orientation
282) sheet_overview for region detection (ids/bounds/kind/confidence)
293) For structured data: table_profile for quick column sense, then read_table with region_id/range, filters, sampling
304) For spot checks: range_values or find_value (label mode for key-value sheets)
31
32TOOL SELECTION:
33- table_profile: Fast column/type summary before wide reads.
34- read_table: Structured table extraction. Prefer region_id or tight range; use limit + sample_mode.
35- sheet_formula_map: Get formula overview. Use limit param for large sheets (e.g., limit=10). \
36Use sort_by='complexity' for most complex formulas first, or 'count' for most repeated. \
37Use range param to scope to specific region.
38- formula_trace: Trace ONE cell's precedents/dependents. Use AFTER formula_map \
39to dive deep on specific outputs (e.g., trace the total cell to understand calc flow).
40- sheet_page: Raw cell dump. Use ONLY when region detection fails or for \
41unstructured sheets. Prefer read_table for tabular data.
42- find_value with mode='label': For key-value layouts (label in col A, value in col B). \
43Use direction='right' or 'below' hints.
44- find_formula: Search formulas. Default returns no context and only first 50 matches. \
45Use include_context=true for header+cell snapshots, and use limit/offset to page.
46
47RANGES: Use A1 notation (e.g., A1:C10). Prefer region_id when available.
48
49DATES: Cells with date formats return ISO-8601 strings (YYYY-MM-DD).
50
51Keep payloads small. Page through large sheets.";
52
53const WRITE_INSTRUCTIONS: &str = "
54
55WRITE/RECALC TOOLS (enabled):
56Fork-based editing allows 'what-if' analysis without modifying original files.
57
58WORKFLOW:
591) create_fork: Create editable copy of a workbook. Returns fork_id.
602) Optional: checkpoint_fork before large edits.
613) edit_batch/transform_batch/style_batch/structure_batch/apply_formula_pattern: Apply edits to the fork.
624) recalculate: Trigger LibreOffice to recompute all formulas.
635) get_changeset: Diff fork against original. Use filters/limit/offset to keep it small.
64 Optional: screenshot_sheet to capture a visual view of a range (original or fork).
656) save_fork: Write changes to file.
667) discard_fork: Delete fork without saving.
67
68SAFETY:
69- checkpoint_fork before large/structural edits; restore_checkpoint to rollback if needed.
70- 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.
71
72TOOL DETAILS:
73- create_fork: Only .xlsx supported. Returns fork_id for subsequent operations.
74- edit_batch: {fork_id, sheet_name, edits:[{address, value, is_formula}]}. \
75Formulas should NOT include leading '='.
76- transform_batch: Range-first clear/fill/replace. Prefer for bulk edits (blank/fill/rename) to avoid per-cell edit_batch bloat.
77- recalculate: Required after edit_batch to update formula results. \
78May take several seconds for complex workbooks.
79- get_changeset: Returns a paged diff + summary. Use limit/offset to page. \
80Use include_types/exclude_types/include_subtypes/exclude_subtypes to filter (e.g. exclude_subtypes=['recalc_result']). \
81Use summary_only=true when you only need counts.
82- screenshot_sheet: {workbook_or_fork_id, sheet_name, range?}. Renders a cropped PNG for inspecting an area visually.
83 workbook_or_fork_id may be either a real workbook_id OR a fork_id (to screenshot an edited fork).
84 Returns a file:// URI under workspace_root/screenshots/ (Docker default: /data/screenshots/).
85 DO NOT call save_fork just to get a screenshot.
86 If formulas changed, run recalculate on the fork first.
87- save_fork: Requires target_path for new file location.
88 If target_path is relative, it is resolved under workspace_root (Docker default: `/data`).
89 Overwriting original requires server --allow-overwrite flag.
90 Use drop_fork=false to keep fork active after saving (default: true drops fork).
91 Validates base file unchanged since fork creation.
92- get_edits: List all edits applied to a fork (before recalculate).
93- list_forks: See all active forks.
94- checkpoint_fork: Snapshot a fork to a checkpoint for high-fidelity undo.
95- list_checkpoints: List checkpoints for a fork.
96- restore_checkpoint: Restore a fork to a checkpoint (overwrites fork file; clears newer staged changes).
97- delete_checkpoint: Delete a checkpoint.
98- list_staged_changes: List staged (previewed) changes for a fork.
99- apply_staged_change: Apply a staged change to the fork.
100- discard_staged_change: Discard a staged change.
101
102BEST PRACTICES:
103- Always recalculate after edit_batch before get_changeset.
104- Review changeset before save_fork to verify expected changes.
105- Use screenshot_sheet for quick visual inspection; save_fork is ONLY for exporting a workbook file.
106- Discard forks when done to free resources (auto-cleanup after 1 hour).
107- For large edits, batch multiple cells in single edit_batch call.";
108
109fn build_instructions(recalc_enabled: bool) -> String {
110 let mut instructions = BASE_INSTRUCTIONS.to_string();
111 if recalc_enabled {
112 instructions.push_str(WRITE_INSTRUCTIONS);
113 } else {
114 instructions.push_str("\n\nRead-only mode. Write/recalc tools disabled.");
115 }
116 instructions
117}
118
119#[derive(Clone)]
120pub struct SpreadsheetServer {
121 state: Arc<AppState>,
122 tool_router: ToolRouter<SpreadsheetServer>,
123}
124
125impl SpreadsheetServer {
126 pub async fn new(config: Arc<ServerConfig>) -> Result<Self> {
127 config.ensure_workspace_root()?;
128 let state = Arc::new(AppState::new(config));
129 Ok(Self::from_state(state))
130 }
131
132 pub fn from_state(state: Arc<AppState>) -> Self {
133 #[allow(unused_mut)]
134 let mut router = Self::tool_router();
135
136 #[cfg(feature = "recalc")]
137 {
138 router.merge(Self::fork_tool_router());
139 }
140
141 Self {
142 state,
143 tool_router: router,
144 }
145 }
146
147 pub async fn run_stdio(self) -> Result<()> {
148 let service = self
149 .serve(stdio())
150 .await
151 .inspect_err(|error| tracing::error!("serving error: {:?}", error))?;
152 service.waiting().await?;
153 Ok(())
154 }
155
156 pub async fn run(self) -> Result<()> {
157 self.run_stdio().await
158 }
159
160 fn ensure_tool_enabled(&self, tool: &str) -> Result<()> {
161 tracing::info!(tool = tool, "tool invocation requested");
162 if self.state.config().is_tool_enabled(tool) {
163 Ok(())
164 } else {
165 Err(ToolDisabledError::new(tool).into())
166 }
167 }
168
169 #[cfg(feature = "recalc")]
170 fn ensure_recalc_enabled(&self, tool: &str) -> Result<()> {
171 self.ensure_tool_enabled(tool)?;
172 if self.state.config().recalc_enabled {
173 Ok(())
174 } else {
175 Err(RecalcDisabledError.into())
176 }
177 }
178}
179
180#[tool_router]
181impl SpreadsheetServer {
182 #[tool(
183 name = "list_workbooks",
184 description = "List spreadsheet files in the workspace"
185 )]
186 pub async fn list_workbooks(
187 &self,
188 Parameters(params): Parameters<tools::ListWorkbooksParams>,
189 ) -> Result<Json<WorkbookListResponse>, McpError> {
190 self.ensure_tool_enabled("list_workbooks")
191 .map_err(to_mcp_error)?;
192 tools::list_workbooks(self.state.clone(), params)
193 .await
194 .map(Json)
195 .map_err(to_mcp_error)
196 }
197
198 #[tool(name = "describe_workbook", description = "Describe workbook metadata")]
199 pub async fn describe_workbook(
200 &self,
201 Parameters(params): Parameters<tools::DescribeWorkbookParams>,
202 ) -> Result<Json<WorkbookDescription>, McpError> {
203 self.ensure_tool_enabled("describe_workbook")
204 .map_err(to_mcp_error)?;
205 tools::describe_workbook(self.state.clone(), params)
206 .await
207 .map(Json)
208 .map_err(to_mcp_error)
209 }
210
211 #[tool(
212 name = "workbook_summary",
213 description = "Summarize workbook regions and entry points"
214 )]
215 pub async fn workbook_summary(
216 &self,
217 Parameters(params): Parameters<tools::WorkbookSummaryParams>,
218 ) -> Result<Json<WorkbookSummaryResponse>, McpError> {
219 self.ensure_tool_enabled("workbook_summary")
220 .map_err(to_mcp_error)?;
221 tools::workbook_summary(self.state.clone(), params)
222 .await
223 .map(Json)
224 .map_err(to_mcp_error)
225 }
226
227 #[tool(name = "list_sheets", description = "List sheets with summaries")]
228 pub async fn list_sheets(
229 &self,
230 Parameters(params): Parameters<tools::ListSheetsParams>,
231 ) -> Result<Json<SheetListResponse>, McpError> {
232 self.ensure_tool_enabled("list_sheets")
233 .map_err(to_mcp_error)?;
234 tools::list_sheets(self.state.clone(), params)
235 .await
236 .map(Json)
237 .map_err(to_mcp_error)
238 }
239
240 #[tool(
241 name = "sheet_overview",
242 description = "Get narrative overview for a sheet"
243 )]
244 pub async fn sheet_overview(
245 &self,
246 Parameters(params): Parameters<tools::SheetOverviewParams>,
247 ) -> Result<Json<SheetOverviewResponse>, McpError> {
248 self.ensure_tool_enabled("sheet_overview")
249 .map_err(to_mcp_error)?;
250 tools::sheet_overview(self.state.clone(), params)
251 .await
252 .map(Json)
253 .map_err(to_mcp_error)
254 }
255
256 #[tool(name = "sheet_page", description = "Page through sheet cells")]
257 pub async fn sheet_page(
258 &self,
259 Parameters(params): Parameters<tools::SheetPageParams>,
260 ) -> Result<Json<SheetPageResponse>, McpError> {
261 self.ensure_tool_enabled("sheet_page")
262 .map_err(to_mcp_error)?;
263 tools::sheet_page(self.state.clone(), params)
264 .await
265 .map(Json)
266 .map_err(to_mcp_error)
267 }
268
269 #[tool(name = "find_value", description = "Search cell values or labels")]
270 pub async fn find_value(
271 &self,
272 Parameters(params): Parameters<tools::FindValueParams>,
273 ) -> Result<Json<FindValueResponse>, McpError> {
274 self.ensure_tool_enabled("find_value")
275 .map_err(to_mcp_error)?;
276 tools::find_value(self.state.clone(), params)
277 .await
278 .map(Json)
279 .map_err(to_mcp_error)
280 }
281
282 #[tool(
283 name = "read_table",
284 description = "Read structured data from a range or table"
285 )]
286 pub async fn read_table(
287 &self,
288 Parameters(params): Parameters<tools::ReadTableParams>,
289 ) -> Result<Json<ReadTableResponse>, McpError> {
290 self.ensure_tool_enabled("read_table")
291 .map_err(to_mcp_error)?;
292 tools::read_table(self.state.clone(), params)
293 .await
294 .map(Json)
295 .map_err(to_mcp_error)
296 }
297
298 #[tool(name = "table_profile", description = "Profile a region or table")]
299 pub async fn table_profile(
300 &self,
301 Parameters(params): Parameters<tools::TableProfileParams>,
302 ) -> Result<Json<TableProfileResponse>, McpError> {
303 self.ensure_tool_enabled("table_profile")
304 .map_err(to_mcp_error)?;
305 tools::table_profile(self.state.clone(), params)
306 .await
307 .map(Json)
308 .map_err(to_mcp_error)
309 }
310
311 #[tool(
312 name = "range_values",
313 description = "Fetch raw values for specific ranges"
314 )]
315 pub async fn range_values(
316 &self,
317 Parameters(params): Parameters<tools::RangeValuesParams>,
318 ) -> Result<Json<RangeValuesResponse>, McpError> {
319 self.ensure_tool_enabled("range_values")
320 .map_err(to_mcp_error)?;
321 tools::range_values(self.state.clone(), params)
322 .await
323 .map(Json)
324 .map_err(to_mcp_error)
325 }
326
327 #[tool(
328 name = "sheet_statistics",
329 description = "Get aggregated sheet statistics"
330 )]
331 pub async fn sheet_statistics(
332 &self,
333 Parameters(params): Parameters<tools::SheetStatisticsParams>,
334 ) -> Result<Json<SheetStatisticsResponse>, McpError> {
335 self.ensure_tool_enabled("sheet_statistics")
336 .map_err(to_mcp_error)?;
337 tools::sheet_statistics(self.state.clone(), params)
338 .await
339 .map(Json)
340 .map_err(to_mcp_error)
341 }
342
343 #[tool(
344 name = "sheet_formula_map",
345 description = "Summarize formula groups across a sheet"
346 )]
347 pub async fn sheet_formula_map(
348 &self,
349 Parameters(params): Parameters<tools::SheetFormulaMapParams>,
350 ) -> Result<Json<SheetFormulaMapResponse>, McpError> {
351 self.ensure_tool_enabled("sheet_formula_map")
352 .map_err(to_mcp_error)?;
353 tools::sheet_formula_map(self.state.clone(), params)
354 .await
355 .map(Json)
356 .map_err(to_mcp_error)
357 }
358
359 #[tool(
360 name = "formula_trace",
361 description = "Trace formula precedents or dependents"
362 )]
363 pub async fn formula_trace(
364 &self,
365 Parameters(params): Parameters<tools::FormulaTraceParams>,
366 ) -> Result<Json<FormulaTraceResponse>, McpError> {
367 self.ensure_tool_enabled("formula_trace")
368 .map_err(to_mcp_error)?;
369 tools::formula_trace(self.state.clone(), params)
370 .await
371 .map(Json)
372 .map_err(to_mcp_error)
373 }
374
375 #[tool(name = "named_ranges", description = "List named ranges and tables")]
376 pub async fn named_ranges(
377 &self,
378 Parameters(params): Parameters<tools::NamedRangesParams>,
379 ) -> Result<Json<NamedRangesResponse>, McpError> {
380 self.ensure_tool_enabled("named_ranges")
381 .map_err(to_mcp_error)?;
382 tools::named_ranges(self.state.clone(), params)
383 .await
384 .map(Json)
385 .map_err(to_mcp_error)
386 }
387
388 #[tool(
389 name = "find_formula",
390 description = "Search formulas containing text. Defaults: include_context=false, limit=50; use offset for paging."
391 )]
392 pub async fn find_formula(
393 &self,
394 Parameters(params): Parameters<tools::FindFormulaParams>,
395 ) -> Result<Json<FindFormulaResponse>, McpError> {
396 self.ensure_tool_enabled("find_formula")
397 .map_err(to_mcp_error)?;
398 tools::find_formula(self.state.clone(), params)
399 .await
400 .map(Json)
401 .map_err(to_mcp_error)
402 }
403
404 #[tool(name = "scan_volatiles", description = "Scan for volatile formulas")]
405 pub async fn scan_volatiles(
406 &self,
407 Parameters(params): Parameters<tools::ScanVolatilesParams>,
408 ) -> Result<Json<VolatileScanResponse>, McpError> {
409 self.ensure_tool_enabled("scan_volatiles")
410 .map_err(to_mcp_error)?;
411 tools::scan_volatiles(self.state.clone(), params)
412 .await
413 .map(Json)
414 .map_err(to_mcp_error)
415 }
416
417 #[tool(
418 name = "sheet_styles",
419 description = "Summarise style usage and properties for a sheet"
420 )]
421 pub async fn sheet_styles(
422 &self,
423 Parameters(params): Parameters<tools::SheetStylesParams>,
424 ) -> Result<Json<SheetStylesResponse>, McpError> {
425 self.ensure_tool_enabled("sheet_styles")
426 .map_err(to_mcp_error)?;
427 tools::sheet_styles(self.state.clone(), params)
428 .await
429 .map(Json)
430 .map_err(to_mcp_error)
431 }
432
433 #[tool(
434 name = "workbook_style_summary",
435 description = "Summarise style usage, theme colors, and conditional formats across a workbook"
436 )]
437 pub async fn workbook_style_summary(
438 &self,
439 Parameters(params): Parameters<tools::WorkbookStyleSummaryParams>,
440 ) -> Result<Json<WorkbookStyleSummaryResponse>, McpError> {
441 self.ensure_tool_enabled("workbook_style_summary")
442 .map_err(to_mcp_error)?;
443 tools::workbook_style_summary(self.state.clone(), params)
444 .await
445 .map(Json)
446 .map_err(to_mcp_error)
447 }
448
449 #[tool(
450 name = "get_manifest_stub",
451 description = "Generate manifest scaffold for workbook"
452 )]
453 pub async fn get_manifest_stub(
454 &self,
455 Parameters(params): Parameters<tools::ManifestStubParams>,
456 ) -> Result<Json<ManifestStubResponse>, McpError> {
457 self.ensure_tool_enabled("get_manifest_stub")
458 .map_err(to_mcp_error)?;
459 tools::get_manifest_stub(self.state.clone(), params)
460 .await
461 .map(Json)
462 .map_err(to_mcp_error)
463 }
464
465 #[tool(name = "close_workbook", description = "Evict a workbook from cache")]
466 pub async fn close_workbook(
467 &self,
468 Parameters(params): Parameters<tools::CloseWorkbookParams>,
469 ) -> Result<Json<CloseWorkbookResponse>, McpError> {
470 self.ensure_tool_enabled("close_workbook")
471 .map_err(to_mcp_error)?;
472 tools::close_workbook(self.state.clone(), params)
473 .await
474 .map(Json)
475 .map_err(to_mcp_error)
476 }
477}
478
479#[cfg(feature = "recalc")]
480#[tool_router(router = fork_tool_router)]
481impl SpreadsheetServer {
482 #[tool(
483 name = "create_fork",
484 description = "Create a temporary editable copy of a workbook for what-if analysis"
485 )]
486 pub async fn create_fork(
487 &self,
488 Parameters(params): Parameters<tools::fork::CreateForkParams>,
489 ) -> Result<Json<tools::fork::CreateForkResponse>, McpError> {
490 self.ensure_recalc_enabled("create_fork")
491 .map_err(to_mcp_error)?;
492 tools::fork::create_fork(self.state.clone(), params)
493 .await
494 .map(Json)
495 .map_err(to_mcp_error)
496 }
497
498 #[tool(
499 name = "edit_batch",
500 description = "Apply batch edits (values or formulas) to a fork"
501 )]
502 pub async fn edit_batch(
503 &self,
504 Parameters(params): Parameters<tools::fork::EditBatchParams>,
505 ) -> Result<Json<tools::fork::EditBatchResponse>, McpError> {
506 self.ensure_recalc_enabled("edit_batch")
507 .map_err(to_mcp_error)?;
508 tools::fork::edit_batch(self.state.clone(), params)
509 .await
510 .map(Json)
511 .map_err(to_mcp_error)
512 }
513
514 #[tool(
515 name = "transform_batch",
516 description = "Range-oriented transforms for a fork (clear/fill/replace). Supports targets by range, region_id, or explicit cells. \
517Mode: preview or apply (default apply)."
518 )]
519 pub async fn transform_batch(
520 &self,
521 Parameters(params): Parameters<tools::fork::TransformBatchParams>,
522 ) -> Result<Json<tools::fork::TransformBatchResponse>, McpError> {
523 self.ensure_recalc_enabled("transform_batch")
524 .map_err(to_mcp_error)?;
525 tools::fork::transform_batch(self.state.clone(), params)
526 .await
527 .map(Json)
528 .map_err(to_mcp_error)
529 }
530
531 #[tool(
532 name = "style_batch",
533 description = "Apply batch style edits to a fork. Supports targets by range, region_id, or explicit cells. \
534Mode: preview or apply (default apply). Op mode: merge (default), set, or clear."
535 )]
536 pub async fn style_batch(
537 &self,
538 Parameters(params): Parameters<tools::fork::StyleBatchParams>,
539 ) -> Result<Json<tools::fork::StyleBatchResponse>, McpError> {
540 self.ensure_recalc_enabled("style_batch")
541 .map_err(to_mcp_error)?;
542 tools::fork::style_batch(self.state.clone(), params)
543 .await
544 .map(Json)
545 .map_err(to_mcp_error)
546 }
547
548 #[tool(
549 name = "apply_formula_pattern",
550 description = "Autofill-like formula pattern application over a target range in a fork. \
551Provide base_formula at anchor_cell, then fill across target_range. \
552Mode: preview or apply (default apply). relative_mode: excel (default), abs_cols, abs_rows. \
553fill_direction: down, right, both (default both)."
554 )]
555 pub async fn apply_formula_pattern(
556 &self,
557 Parameters(params): Parameters<tools::fork::ApplyFormulaPatternParams>,
558 ) -> Result<Json<tools::fork::ApplyFormulaPatternResponse>, McpError> {
559 self.ensure_recalc_enabled("apply_formula_pattern")
560 .map_err(to_mcp_error)?;
561 tools::fork::apply_formula_pattern(self.state.clone(), params)
562 .await
563 .map(Json)
564 .map_err(to_mcp_error)
565 }
566
567 #[tool(
568 name = "structure_batch",
569 description = "Apply structural edits to a fork (rows/cols/sheets). \
570Mode: preview or apply (default apply). Note: structural edits may not fully rewrite formulas/named ranges like Excel; \
571run recalculate and review get_changeset after applying."
572 )]
573 pub async fn structure_batch(
574 &self,
575 Parameters(params): Parameters<tools::fork::StructureBatchParams>,
576 ) -> Result<Json<tools::fork::StructureBatchResponse>, McpError> {
577 self.ensure_recalc_enabled("structure_batch")
578 .map_err(to_mcp_error)?;
579 tools::fork::structure_batch(self.state.clone(), params)
580 .await
581 .map(Json)
582 .map_err(to_mcp_error)
583 }
584
585 #[tool(name = "get_edits", description = "List all edits applied to a fork")]
586 pub async fn get_edits(
587 &self,
588 Parameters(params): Parameters<tools::fork::GetEditsParams>,
589 ) -> Result<Json<tools::fork::GetEditsResponse>, McpError> {
590 self.ensure_recalc_enabled("get_edits")
591 .map_err(to_mcp_error)?;
592 tools::fork::get_edits(self.state.clone(), params)
593 .await
594 .map(Json)
595 .map_err(to_mcp_error)
596 }
597
598 #[tool(
599 name = "get_changeset",
600 description = "Calculate diff between fork and base workbook. Defaults: limit=200. Supports limit/offset paging and type/subtype filters; returns summary."
601 )]
602 pub async fn get_changeset(
603 &self,
604 Parameters(params): Parameters<tools::fork::GetChangesetParams>,
605 ) -> Result<Json<tools::fork::GetChangesetResponse>, McpError> {
606 self.ensure_recalc_enabled("get_changeset")
607 .map_err(to_mcp_error)?;
608 tools::fork::get_changeset(self.state.clone(), params)
609 .await
610 .map(Json)
611 .map_err(to_mcp_error)
612 }
613
614 #[tool(
615 name = "recalculate",
616 description = "Recalculate all formulas in a fork using LibreOffice"
617 )]
618 pub async fn recalculate(
619 &self,
620 Parameters(params): Parameters<tools::fork::RecalculateParams>,
621 ) -> Result<Json<tools::fork::RecalculateResponse>, McpError> {
622 self.ensure_recalc_enabled("recalculate")
623 .map_err(to_mcp_error)?;
624 tools::fork::recalculate(self.state.clone(), params)
625 .await
626 .map(Json)
627 .map_err(to_mcp_error)
628 }
629
630 #[tool(name = "list_forks", description = "List all active forks")]
631 pub async fn list_forks(
632 &self,
633 Parameters(params): Parameters<tools::fork::ListForksParams>,
634 ) -> Result<Json<tools::fork::ListForksResponse>, McpError> {
635 self.ensure_recalc_enabled("list_forks")
636 .map_err(to_mcp_error)?;
637 tools::fork::list_forks(self.state.clone(), params)
638 .await
639 .map(Json)
640 .map_err(to_mcp_error)
641 }
642
643 #[tool(name = "discard_fork", description = "Discard a fork without saving")]
644 pub async fn discard_fork(
645 &self,
646 Parameters(params): Parameters<tools::fork::DiscardForkParams>,
647 ) -> Result<Json<tools::fork::DiscardForkResponse>, McpError> {
648 self.ensure_recalc_enabled("discard_fork")
649 .map_err(to_mcp_error)?;
650 tools::fork::discard_fork(self.state.clone(), params)
651 .await
652 .map(Json)
653 .map_err(to_mcp_error)
654 }
655
656 #[tool(
657 name = "save_fork",
658 description = "Save fork changes to target path (defaults to overwriting original)"
659 )]
660 pub async fn save_fork(
661 &self,
662 Parameters(params): Parameters<tools::fork::SaveForkParams>,
663 ) -> Result<Json<tools::fork::SaveForkResponse>, McpError> {
664 self.ensure_recalc_enabled("save_fork")
665 .map_err(to_mcp_error)?;
666 tools::fork::save_fork(self.state.clone(), params)
667 .await
668 .map(Json)
669 .map_err(to_mcp_error)
670 }
671
672 #[tool(
673 name = "checkpoint_fork",
674 description = "Create a high-fidelity checkpoint snapshot of a fork"
675 )]
676 pub async fn checkpoint_fork(
677 &self,
678 Parameters(params): Parameters<tools::fork::CheckpointForkParams>,
679 ) -> Result<Json<tools::fork::CheckpointForkResponse>, McpError> {
680 self.ensure_recalc_enabled("checkpoint_fork")
681 .map_err(to_mcp_error)?;
682 tools::fork::checkpoint_fork(self.state.clone(), params)
683 .await
684 .map(Json)
685 .map_err(to_mcp_error)
686 }
687
688 #[tool(name = "list_checkpoints", description = "List checkpoints for a fork")]
689 pub async fn list_checkpoints(
690 &self,
691 Parameters(params): Parameters<tools::fork::ListCheckpointsParams>,
692 ) -> Result<Json<tools::fork::ListCheckpointsResponse>, McpError> {
693 self.ensure_recalc_enabled("list_checkpoints")
694 .map_err(to_mcp_error)?;
695 tools::fork::list_checkpoints(self.state.clone(), params)
696 .await
697 .map(Json)
698 .map_err(to_mcp_error)
699 }
700
701 #[tool(
702 name = "restore_checkpoint",
703 description = "Restore a fork to a checkpoint"
704 )]
705 pub async fn restore_checkpoint(
706 &self,
707 Parameters(params): Parameters<tools::fork::RestoreCheckpointParams>,
708 ) -> Result<Json<tools::fork::RestoreCheckpointResponse>, McpError> {
709 self.ensure_recalc_enabled("restore_checkpoint")
710 .map_err(to_mcp_error)?;
711 tools::fork::restore_checkpoint(self.state.clone(), params)
712 .await
713 .map(Json)
714 .map_err(to_mcp_error)
715 }
716
717 #[tool(
718 name = "delete_checkpoint",
719 description = "Delete a checkpoint from a fork"
720 )]
721 pub async fn delete_checkpoint(
722 &self,
723 Parameters(params): Parameters<tools::fork::DeleteCheckpointParams>,
724 ) -> Result<Json<tools::fork::DeleteCheckpointResponse>, McpError> {
725 self.ensure_recalc_enabled("delete_checkpoint")
726 .map_err(to_mcp_error)?;
727 tools::fork::delete_checkpoint(self.state.clone(), params)
728 .await
729 .map(Json)
730 .map_err(to_mcp_error)
731 }
732
733 #[tool(
734 name = "list_staged_changes",
735 description = "List previewed/staged changes for a fork"
736 )]
737 pub async fn list_staged_changes(
738 &self,
739 Parameters(params): Parameters<tools::fork::ListStagedChangesParams>,
740 ) -> Result<Json<tools::fork::ListStagedChangesResponse>, McpError> {
741 self.ensure_recalc_enabled("list_staged_changes")
742 .map_err(to_mcp_error)?;
743 tools::fork::list_staged_changes(self.state.clone(), params)
744 .await
745 .map(Json)
746 .map_err(to_mcp_error)
747 }
748
749 #[tool(
750 name = "apply_staged_change",
751 description = "Apply a staged change to a fork"
752 )]
753 pub async fn apply_staged_change(
754 &self,
755 Parameters(params): Parameters<tools::fork::ApplyStagedChangeParams>,
756 ) -> Result<Json<tools::fork::ApplyStagedChangeResponse>, McpError> {
757 self.ensure_recalc_enabled("apply_staged_change")
758 .map_err(to_mcp_error)?;
759 tools::fork::apply_staged_change(self.state.clone(), params)
760 .await
761 .map(Json)
762 .map_err(to_mcp_error)
763 }
764
765 #[tool(
766 name = "discard_staged_change",
767 description = "Discard a staged change without applying it"
768 )]
769 pub async fn discard_staged_change(
770 &self,
771 Parameters(params): Parameters<tools::fork::DiscardStagedChangeParams>,
772 ) -> Result<Json<tools::fork::DiscardStagedChangeResponse>, McpError> {
773 self.ensure_recalc_enabled("discard_staged_change")
774 .map_err(to_mcp_error)?;
775 tools::fork::discard_staged_change(self.state.clone(), params)
776 .await
777 .map(Json)
778 .map_err(to_mcp_error)
779 }
780
781 #[tool(
782 name = "screenshot_sheet",
783 description = "Capture a visual screenshot of a spreadsheet region as PNG. \
784 Returns file URI. Max range: 100 rows x 30 columns. Default: A1:M40."
785 )]
786 pub async fn screenshot_sheet(
787 &self,
788 Parameters(params): Parameters<tools::fork::ScreenshotSheetParams>,
789 ) -> Result<rmcp::model::CallToolResult, McpError> {
790 use base64::Engine;
791 use rmcp::model::Content;
792
793 self.ensure_recalc_enabled("screenshot_sheet")
794 .map_err(to_mcp_error)?;
795
796 let response = tools::fork::screenshot_sheet(self.state.clone(), params)
797 .await
798 .map_err(to_mcp_error)?;
799
800 let mut content = Vec::new();
801
802 let fs_path = response
803 .output_path
804 .strip_prefix("file://")
805 .ok_or_else(|| to_mcp_error(anyhow::anyhow!("unexpected screenshot output_path")))?;
806 let bytes = tokio::fs::read(fs_path)
807 .await
808 .map_err(|e| to_mcp_error(anyhow::anyhow!("failed to read screenshot: {}", e)))?;
809
810 let data = base64::engine::general_purpose::STANDARD.encode(bytes);
811 content.push(Content::image(data, "image/png"));
812
813 content.push(Content::text(response.output_path.clone()));
815
816 let structured_content = serde_json::to_value(&response)
817 .map_err(|e| to_mcp_error(anyhow::anyhow!("failed to serialize response: {}", e)))?;
818
819 Ok(rmcp::model::CallToolResult {
820 content,
821 structured_content: Some(structured_content),
822 is_error: Some(false),
823 meta: None,
824 })
825 }
826}
827
828#[tool_handler(router = self.tool_router)]
829impl ServerHandler for SpreadsheetServer {
830 fn get_info(&self) -> ServerInfo {
831 let recalc_enabled = {
832 #[cfg(feature = "recalc")]
833 {
834 self.state.config().recalc_enabled
835 }
836 #[cfg(not(feature = "recalc"))]
837 {
838 false
839 }
840 };
841
842 ServerInfo {
843 capabilities: ServerCapabilities::builder().enable_tools().build(),
844 server_info: Implementation::from_build_env(),
845 instructions: Some(build_instructions(recalc_enabled)),
846 ..ServerInfo::default()
847 }
848 }
849}
850
851fn to_mcp_error(error: anyhow::Error) -> McpError {
852 if error.downcast_ref::<ToolDisabledError>().is_some() {
853 McpError::invalid_request(error.to_string(), None)
854 } else {
855 McpError::internal_error(error.to_string(), None)
856 }
857}
858
859#[derive(Debug, Error)]
860#[error("tool '{tool_name}' is disabled by server configuration")]
861struct ToolDisabledError {
862 tool_name: String,
863}
864
865impl ToolDisabledError {
866 fn new(tool_name: &str) -> Self {
867 Self {
868 tool_name: tool_name.to_ascii_lowercase(),
869 }
870 }
871}
872
873#[cfg(feature = "recalc")]
874#[derive(Debug, Error)]
875#[error("recalc/write tools are disabled (set SPREADSHEET_MCP_RECALC_ENABLED=true)")]
876struct RecalcDisabledError;