spreadsheet_mcp/
server.rs

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        // Always include a small text hint for clients that ignore structured_content.
814        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;