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, WorkbookSummaryResponse,
8};
9use crate::state::AppState;
10use crate::tools;
11use anyhow::Result;
12use rmcp::{
13    ErrorData as McpError, Json, ServerHandler, ServiceExt,
14    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
15    model::{Implementation, ServerCapabilities, ServerInfo},
16    tool, tool_handler, tool_router,
17    transport::stdio,
18};
19use std::sync::Arc;
20use thiserror::Error;
21
22const BASE_INSTRUCTIONS: &str = "\
23Spreadsheet MCP: optimized for spreadsheet analysis.
24
25WORKFLOW:
261) list_workbooks → list_sheets → workbook_summary for orientation
272) sheet_overview for region detection (ids/bounds/kind/confidence)
283) For structured data: read_table with region_id, filters, sampling
294) For spot checks: range_values or find_value (label mode for key-value sheets)
30
31TOOL SELECTION:
32- sheet_formula_map: Get formula overview. Use limit param for large sheets (e.g., limit=10). \
33Use sort_by='complexity' for most complex formulas first, or 'count' for most repeated. \
34Use range param to scope to specific region.
35- formula_trace: Trace ONE cell's precedents/dependents. Use AFTER formula_map \
36to dive deep on specific outputs (e.g., trace the total cell to understand calc flow).
37- sheet_page: Raw cell dump. Use ONLY when region detection fails or for \
38unstructured sheets. Prefer read_table for tabular data.
39- find_value with mode='label': For key-value layouts (label in col A, value in col B). \
40Use direction='right' or 'below' hints.
41
42DATES: Cells with date formats return ISO-8601 strings (YYYY-MM-DD).
43
44Keep payloads small. Page through large sheets.";
45
46const WRITE_INSTRUCTIONS: &str = "
47
48WRITE/RECALC TOOLS (enabled):
49Fork-based editing allows 'what-if' analysis without modifying original files.
50
51WORKFLOW:
521) create_fork: Create editable copy of a workbook. Returns fork_id.
532) edit_batch: Apply cell changes (values or formulas) to the fork.
543) recalculate: Trigger LibreOffice to recompute all formulas.
554) get_changeset: Diff fork against original. Shows cell/table/name changes.
565) save_fork: Write changes to file.
576) discard_fork: Delete fork without saving.
58
59TOOL DETAILS:
60- create_fork: Only .xlsx supported. Returns fork_id for subsequent operations.
61- edit_batch: Accepts array of {sheet, cell, value} or {sheet, cell, formula}. \
62Formulas should NOT include leading '='.
63- recalculate: Required after edit_batch to update formula results. \
64May take several seconds for complex workbooks.
65- get_changeset: Returns cell-level diffs with modification types: \
66ValueEdit, FormulaEdit, RecalcResult, Added, Deleted. \
67Use sheet_name param to filter to specific sheet.
68- save_fork: Requires target_path for new file location. \
69Overwriting original requires server --allow-overwrite flag. \
70Use drop_fork=false to keep fork active after saving (default: true drops fork). \
71Validates base file unchanged since fork creation.
72- get_edits: List all edits applied to a fork (before recalculate).
73- list_forks: See all active forks.
74
75BEST PRACTICES:
76- Always recalculate after edit_batch before get_changeset.
77- Review changeset before save_fork to verify expected changes.
78- Discard forks when done to free resources (auto-cleanup after 1 hour).
79- For large edits, batch multiple cells in single edit_batch call.";
80
81fn build_instructions(recalc_enabled: bool) -> String {
82    let mut instructions = BASE_INSTRUCTIONS.to_string();
83    if recalc_enabled {
84        instructions.push_str(WRITE_INSTRUCTIONS);
85    } else {
86        instructions.push_str("\n\nRead-only mode. Write/recalc tools disabled.");
87    }
88    instructions
89}
90
91#[derive(Clone)]
92pub struct SpreadsheetServer {
93    state: Arc<AppState>,
94    tool_router: ToolRouter<SpreadsheetServer>,
95}
96
97impl SpreadsheetServer {
98    pub async fn new(config: Arc<ServerConfig>) -> Result<Self> {
99        config.ensure_workspace_root()?;
100        let state = Arc::new(AppState::new(config));
101        Ok(Self::from_state(state))
102    }
103
104    pub fn from_state(state: Arc<AppState>) -> Self {
105        #[allow(unused_mut)]
106        let mut router = Self::tool_router();
107
108        #[cfg(feature = "recalc")]
109        {
110            router.merge(Self::fork_tool_router());
111        }
112
113        Self {
114            state,
115            tool_router: router,
116        }
117    }
118
119    pub async fn run_stdio(self) -> Result<()> {
120        let service = self
121            .serve(stdio())
122            .await
123            .inspect_err(|error| tracing::error!("serving error: {:?}", error))?;
124        service.waiting().await?;
125        Ok(())
126    }
127
128    pub async fn run(self) -> Result<()> {
129        self.run_stdio().await
130    }
131
132    fn ensure_tool_enabled(&self, tool: &str) -> Result<()> {
133        tracing::info!(tool = tool, "tool invocation requested");
134        if self.state.config().is_tool_enabled(tool) {
135            Ok(())
136        } else {
137            Err(ToolDisabledError::new(tool).into())
138        }
139    }
140
141    #[cfg(feature = "recalc")]
142    fn ensure_recalc_enabled(&self, tool: &str) -> Result<()> {
143        self.ensure_tool_enabled(tool)?;
144        if self.state.config().recalc_enabled {
145            Ok(())
146        } else {
147            Err(RecalcDisabledError.into())
148        }
149    }
150}
151
152#[tool_router]
153impl SpreadsheetServer {
154    #[tool(
155        name = "list_workbooks",
156        description = "List spreadsheet files in the workspace"
157    )]
158    pub async fn list_workbooks(
159        &self,
160        Parameters(params): Parameters<tools::ListWorkbooksParams>,
161    ) -> Result<Json<WorkbookListResponse>, McpError> {
162        self.ensure_tool_enabled("list_workbooks")
163            .map_err(to_mcp_error)?;
164        tools::list_workbooks(self.state.clone(), params)
165            .await
166            .map(Json)
167            .map_err(to_mcp_error)
168    }
169
170    #[tool(name = "describe_workbook", description = "Describe workbook metadata")]
171    pub async fn describe_workbook(
172        &self,
173        Parameters(params): Parameters<tools::DescribeWorkbookParams>,
174    ) -> Result<Json<WorkbookDescription>, McpError> {
175        self.ensure_tool_enabled("describe_workbook")
176            .map_err(to_mcp_error)?;
177        tools::describe_workbook(self.state.clone(), params)
178            .await
179            .map(Json)
180            .map_err(to_mcp_error)
181    }
182
183    #[tool(
184        name = "workbook_summary",
185        description = "Summarize workbook regions and entry points"
186    )]
187    pub async fn workbook_summary(
188        &self,
189        Parameters(params): Parameters<tools::WorkbookSummaryParams>,
190    ) -> Result<Json<WorkbookSummaryResponse>, McpError> {
191        self.ensure_tool_enabled("workbook_summary")
192            .map_err(to_mcp_error)?;
193        tools::workbook_summary(self.state.clone(), params)
194            .await
195            .map(Json)
196            .map_err(to_mcp_error)
197    }
198
199    #[tool(name = "list_sheets", description = "List sheets with summaries")]
200    pub async fn list_sheets(
201        &self,
202        Parameters(params): Parameters<tools::ListSheetsParams>,
203    ) -> Result<Json<SheetListResponse>, McpError> {
204        self.ensure_tool_enabled("list_sheets")
205            .map_err(to_mcp_error)?;
206        tools::list_sheets(self.state.clone(), params)
207            .await
208            .map(Json)
209            .map_err(to_mcp_error)
210    }
211
212    #[tool(
213        name = "sheet_overview",
214        description = "Get narrative overview for a sheet"
215    )]
216    pub async fn sheet_overview(
217        &self,
218        Parameters(params): Parameters<tools::SheetOverviewParams>,
219    ) -> Result<Json<SheetOverviewResponse>, McpError> {
220        self.ensure_tool_enabled("sheet_overview")
221            .map_err(to_mcp_error)?;
222        tools::sheet_overview(self.state.clone(), params)
223            .await
224            .map(Json)
225            .map_err(to_mcp_error)
226    }
227
228    #[tool(name = "sheet_page", description = "Page through sheet cells")]
229    pub async fn sheet_page(
230        &self,
231        Parameters(params): Parameters<tools::SheetPageParams>,
232    ) -> Result<Json<SheetPageResponse>, McpError> {
233        self.ensure_tool_enabled("sheet_page")
234            .map_err(to_mcp_error)?;
235        tools::sheet_page(self.state.clone(), params)
236            .await
237            .map(Json)
238            .map_err(to_mcp_error)
239    }
240
241    #[tool(name = "find_value", description = "Search cell values or labels")]
242    pub async fn find_value(
243        &self,
244        Parameters(params): Parameters<tools::FindValueParams>,
245    ) -> Result<Json<FindValueResponse>, McpError> {
246        self.ensure_tool_enabled("find_value")
247            .map_err(to_mcp_error)?;
248        tools::find_value(self.state.clone(), params)
249            .await
250            .map(Json)
251            .map_err(to_mcp_error)
252    }
253
254    #[tool(
255        name = "read_table",
256        description = "Read structured data from a range or table"
257    )]
258    pub async fn read_table(
259        &self,
260        Parameters(params): Parameters<tools::ReadTableParams>,
261    ) -> Result<Json<ReadTableResponse>, McpError> {
262        self.ensure_tool_enabled("read_table")
263            .map_err(to_mcp_error)?;
264        tools::read_table(self.state.clone(), params)
265            .await
266            .map(Json)
267            .map_err(to_mcp_error)
268    }
269
270    #[tool(name = "table_profile", description = "Profile a region or table")]
271    pub async fn table_profile(
272        &self,
273        Parameters(params): Parameters<tools::TableProfileParams>,
274    ) -> Result<Json<TableProfileResponse>, McpError> {
275        self.ensure_tool_enabled("table_profile")
276            .map_err(to_mcp_error)?;
277        tools::table_profile(self.state.clone(), params)
278            .await
279            .map(Json)
280            .map_err(to_mcp_error)
281    }
282
283    #[tool(
284        name = "range_values",
285        description = "Fetch raw values for specific ranges"
286    )]
287    pub async fn range_values(
288        &self,
289        Parameters(params): Parameters<tools::RangeValuesParams>,
290    ) -> Result<Json<RangeValuesResponse>, McpError> {
291        self.ensure_tool_enabled("range_values")
292            .map_err(to_mcp_error)?;
293        tools::range_values(self.state.clone(), params)
294            .await
295            .map(Json)
296            .map_err(to_mcp_error)
297    }
298
299    #[tool(
300        name = "sheet_statistics",
301        description = "Get aggregated sheet statistics"
302    )]
303    pub async fn sheet_statistics(
304        &self,
305        Parameters(params): Parameters<tools::SheetStatisticsParams>,
306    ) -> Result<Json<SheetStatisticsResponse>, McpError> {
307        self.ensure_tool_enabled("sheet_statistics")
308            .map_err(to_mcp_error)?;
309        tools::sheet_statistics(self.state.clone(), params)
310            .await
311            .map(Json)
312            .map_err(to_mcp_error)
313    }
314
315    #[tool(
316        name = "sheet_formula_map",
317        description = "Summarize formula groups across a sheet"
318    )]
319    pub async fn sheet_formula_map(
320        &self,
321        Parameters(params): Parameters<tools::SheetFormulaMapParams>,
322    ) -> Result<Json<SheetFormulaMapResponse>, McpError> {
323        self.ensure_tool_enabled("sheet_formula_map")
324            .map_err(to_mcp_error)?;
325        tools::sheet_formula_map(self.state.clone(), params)
326            .await
327            .map(Json)
328            .map_err(to_mcp_error)
329    }
330
331    #[tool(
332        name = "formula_trace",
333        description = "Trace formula precedents or dependents"
334    )]
335    pub async fn formula_trace(
336        &self,
337        Parameters(params): Parameters<tools::FormulaTraceParams>,
338    ) -> Result<Json<FormulaTraceResponse>, McpError> {
339        self.ensure_tool_enabled("formula_trace")
340            .map_err(to_mcp_error)?;
341        tools::formula_trace(self.state.clone(), params)
342            .await
343            .map(Json)
344            .map_err(to_mcp_error)
345    }
346
347    #[tool(name = "named_ranges", description = "List named ranges and tables")]
348    pub async fn named_ranges(
349        &self,
350        Parameters(params): Parameters<tools::NamedRangesParams>,
351    ) -> Result<Json<NamedRangesResponse>, McpError> {
352        self.ensure_tool_enabled("named_ranges")
353            .map_err(to_mcp_error)?;
354        tools::named_ranges(self.state.clone(), params)
355            .await
356            .map(Json)
357            .map_err(to_mcp_error)
358    }
359
360    #[tool(name = "find_formula", description = "Search formulas containing text")]
361    pub async fn find_formula(
362        &self,
363        Parameters(params): Parameters<tools::FindFormulaParams>,
364    ) -> Result<Json<FindFormulaResponse>, McpError> {
365        self.ensure_tool_enabled("find_formula")
366            .map_err(to_mcp_error)?;
367        tools::find_formula(self.state.clone(), params)
368            .await
369            .map(Json)
370            .map_err(to_mcp_error)
371    }
372
373    #[tool(name = "scan_volatiles", description = "Scan for volatile formulas")]
374    pub async fn scan_volatiles(
375        &self,
376        Parameters(params): Parameters<tools::ScanVolatilesParams>,
377    ) -> Result<Json<VolatileScanResponse>, McpError> {
378        self.ensure_tool_enabled("scan_volatiles")
379            .map_err(to_mcp_error)?;
380        tools::scan_volatiles(self.state.clone(), params)
381            .await
382            .map(Json)
383            .map_err(to_mcp_error)
384    }
385
386    #[tool(
387        name = "sheet_styles",
388        description = "Summarise style usage for a sheet"
389    )]
390    pub async fn sheet_styles(
391        &self,
392        Parameters(params): Parameters<tools::SheetStylesParams>,
393    ) -> Result<Json<SheetStylesResponse>, McpError> {
394        self.ensure_tool_enabled("sheet_styles")
395            .map_err(to_mcp_error)?;
396        tools::sheet_styles(self.state.clone(), params)
397            .await
398            .map(Json)
399            .map_err(to_mcp_error)
400    }
401
402    #[tool(
403        name = "get_manifest_stub",
404        description = "Generate manifest scaffold for workbook"
405    )]
406    pub async fn get_manifest_stub(
407        &self,
408        Parameters(params): Parameters<tools::ManifestStubParams>,
409    ) -> Result<Json<ManifestStubResponse>, McpError> {
410        self.ensure_tool_enabled("get_manifest_stub")
411            .map_err(to_mcp_error)?;
412        tools::get_manifest_stub(self.state.clone(), params)
413            .await
414            .map(Json)
415            .map_err(to_mcp_error)
416    }
417
418    #[tool(name = "close_workbook", description = "Evict a workbook from cache")]
419    pub async fn close_workbook(
420        &self,
421        Parameters(params): Parameters<tools::CloseWorkbookParams>,
422    ) -> Result<Json<CloseWorkbookResponse>, McpError> {
423        self.ensure_tool_enabled("close_workbook")
424            .map_err(to_mcp_error)?;
425        tools::close_workbook(self.state.clone(), params)
426            .await
427            .map(Json)
428            .map_err(to_mcp_error)
429    }
430}
431
432#[cfg(feature = "recalc")]
433#[tool_router(router = fork_tool_router)]
434impl SpreadsheetServer {
435    #[tool(
436        name = "create_fork",
437        description = "Create a temporary editable copy of a workbook for what-if analysis"
438    )]
439    pub async fn create_fork(
440        &self,
441        Parameters(params): Parameters<tools::fork::CreateForkParams>,
442    ) -> Result<Json<tools::fork::CreateForkResponse>, McpError> {
443        self.ensure_recalc_enabled("create_fork")
444            .map_err(to_mcp_error)?;
445        tools::fork::create_fork(self.state.clone(), params)
446            .await
447            .map(Json)
448            .map_err(to_mcp_error)
449    }
450
451    #[tool(
452        name = "edit_batch",
453        description = "Apply batch edits (values or formulas) to a fork"
454    )]
455    pub async fn edit_batch(
456        &self,
457        Parameters(params): Parameters<tools::fork::EditBatchParams>,
458    ) -> Result<Json<tools::fork::EditBatchResponse>, McpError> {
459        self.ensure_recalc_enabled("edit_batch")
460            .map_err(to_mcp_error)?;
461        tools::fork::edit_batch(self.state.clone(), params)
462            .await
463            .map(Json)
464            .map_err(to_mcp_error)
465    }
466
467    #[tool(name = "get_edits", description = "List all edits applied to a fork")]
468    pub async fn get_edits(
469        &self,
470        Parameters(params): Parameters<tools::fork::GetEditsParams>,
471    ) -> Result<Json<tools::fork::GetEditsResponse>, McpError> {
472        self.ensure_recalc_enabled("get_edits")
473            .map_err(to_mcp_error)?;
474        tools::fork::get_edits(self.state.clone(), params)
475            .await
476            .map(Json)
477            .map_err(to_mcp_error)
478    }
479
480    #[tool(
481        name = "get_changeset",
482        description = "Calculate diff between fork and base workbook"
483    )]
484    pub async fn get_changeset(
485        &self,
486        Parameters(params): Parameters<tools::fork::GetChangesetParams>,
487    ) -> Result<Json<tools::fork::GetChangesetResponse>, McpError> {
488        self.ensure_recalc_enabled("get_changeset")
489            .map_err(to_mcp_error)?;
490        tools::fork::get_changeset(self.state.clone(), params)
491            .await
492            .map(Json)
493            .map_err(to_mcp_error)
494    }
495
496    #[tool(
497        name = "recalculate",
498        description = "Recalculate all formulas in a fork using LibreOffice"
499    )]
500    pub async fn recalculate(
501        &self,
502        Parameters(params): Parameters<tools::fork::RecalculateParams>,
503    ) -> Result<Json<tools::fork::RecalculateResponse>, McpError> {
504        self.ensure_recalc_enabled("recalculate")
505            .map_err(to_mcp_error)?;
506        tools::fork::recalculate(self.state.clone(), params)
507            .await
508            .map(Json)
509            .map_err(to_mcp_error)
510    }
511
512    #[tool(name = "list_forks", description = "List all active forks")]
513    pub async fn list_forks(
514        &self,
515        Parameters(params): Parameters<tools::fork::ListForksParams>,
516    ) -> Result<Json<tools::fork::ListForksResponse>, McpError> {
517        self.ensure_recalc_enabled("list_forks")
518            .map_err(to_mcp_error)?;
519        tools::fork::list_forks(self.state.clone(), params)
520            .await
521            .map(Json)
522            .map_err(to_mcp_error)
523    }
524
525    #[tool(name = "discard_fork", description = "Discard a fork without saving")]
526    pub async fn discard_fork(
527        &self,
528        Parameters(params): Parameters<tools::fork::DiscardForkParams>,
529    ) -> Result<Json<tools::fork::DiscardForkResponse>, McpError> {
530        self.ensure_recalc_enabled("discard_fork")
531            .map_err(to_mcp_error)?;
532        tools::fork::discard_fork(self.state.clone(), params)
533            .await
534            .map(Json)
535            .map_err(to_mcp_error)
536    }
537
538    #[tool(
539        name = "save_fork",
540        description = "Save fork changes to target path (defaults to overwriting original)"
541    )]
542    pub async fn save_fork(
543        &self,
544        Parameters(params): Parameters<tools::fork::SaveForkParams>,
545    ) -> Result<Json<tools::fork::SaveForkResponse>, McpError> {
546        self.ensure_recalc_enabled("save_fork")
547            .map_err(to_mcp_error)?;
548        tools::fork::save_fork(self.state.clone(), params)
549            .await
550            .map(Json)
551            .map_err(to_mcp_error)
552    }
553}
554
555#[tool_handler(router = self.tool_router)]
556impl ServerHandler for SpreadsheetServer {
557    fn get_info(&self) -> ServerInfo {
558        let recalc_enabled = {
559            #[cfg(feature = "recalc")]
560            {
561                self.state.config().recalc_enabled
562            }
563            #[cfg(not(feature = "recalc"))]
564            {
565                false
566            }
567        };
568
569        ServerInfo {
570            capabilities: ServerCapabilities::builder().enable_tools().build(),
571            server_info: Implementation::from_build_env(),
572            instructions: Some(build_instructions(recalc_enabled)),
573            ..ServerInfo::default()
574        }
575    }
576}
577
578fn to_mcp_error(error: anyhow::Error) -> McpError {
579    if error.downcast_ref::<ToolDisabledError>().is_some() {
580        McpError::invalid_request(error.to_string(), None)
581    } else {
582        McpError::internal_error(error.to_string(), None)
583    }
584}
585
586#[derive(Debug, Error)]
587#[error("tool '{tool_name}' is disabled by server configuration")]
588struct ToolDisabledError {
589    tool_name: String,
590}
591
592impl ToolDisabledError {
593    fn new(tool_name: &str) -> Self {
594        Self {
595            tool_name: tool_name.to_ascii_lowercase(),
596        }
597    }
598}
599
600#[cfg(feature = "recalc")]
601#[derive(Debug, Error)]
602#[error("recalc/write tools are disabled (set SPREADSHEET_MCP_RECALC_ENABLED=true)")]
603struct RecalcDisabledError;