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