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 SERVER_INSTRUCTIONS: &str = "\
23Spreadsheet Read MCP: optimized for read-only 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. Read-only, no mutations.";
45
46#[derive(Clone)]
47pub struct SpreadsheetServer {
48    state: Arc<AppState>,
49    tool_router: ToolRouter<SpreadsheetServer>,
50}
51
52impl SpreadsheetServer {
53    pub async fn new(config: Arc<ServerConfig>) -> Result<Self> {
54        config.ensure_workspace_root()?;
55        let state = Arc::new(AppState::new(config));
56        Ok(Self::from_state(state))
57    }
58
59    pub fn from_state(state: Arc<AppState>) -> Self {
60        #[allow(unused_mut)]
61        let mut router = Self::tool_router();
62
63        #[cfg(feature = "recalc")]
64        {
65            router.merge(Self::fork_tool_router());
66        }
67
68        Self {
69            state,
70            tool_router: router,
71        }
72    }
73
74    pub async fn run_stdio(self) -> Result<()> {
75        let service = self
76            .serve(stdio())
77            .await
78            .inspect_err(|error| tracing::error!("serving error: {:?}", error))?;
79        service.waiting().await?;
80        Ok(())
81    }
82
83    pub async fn run(self) -> Result<()> {
84        self.run_stdio().await
85    }
86
87    fn ensure_tool_enabled(&self, tool: &str) -> Result<()> {
88        tracing::info!(tool = tool, "tool invocation requested");
89        if self.state.config().is_tool_enabled(tool) {
90            Ok(())
91        } else {
92            Err(ToolDisabledError::new(tool).into())
93        }
94    }
95
96    #[cfg(feature = "recalc")]
97    fn ensure_recalc_enabled(&self, tool: &str) -> Result<()> {
98        self.ensure_tool_enabled(tool)?;
99        if self.state.config().recalc_enabled {
100            Ok(())
101        } else {
102            Err(RecalcDisabledError.into())
103        }
104    }
105}
106
107#[tool_router]
108impl SpreadsheetServer {
109    #[tool(
110        name = "list_workbooks",
111        description = "List spreadsheet files in the workspace"
112    )]
113    pub async fn list_workbooks(
114        &self,
115        Parameters(params): Parameters<tools::ListWorkbooksParams>,
116    ) -> Result<Json<WorkbookListResponse>, McpError> {
117        self.ensure_tool_enabled("list_workbooks")
118            .map_err(to_mcp_error)?;
119        tools::list_workbooks(self.state.clone(), params)
120            .await
121            .map(Json)
122            .map_err(to_mcp_error)
123    }
124
125    #[tool(name = "describe_workbook", description = "Describe workbook metadata")]
126    pub async fn describe_workbook(
127        &self,
128        Parameters(params): Parameters<tools::DescribeWorkbookParams>,
129    ) -> Result<Json<WorkbookDescription>, McpError> {
130        self.ensure_tool_enabled("describe_workbook")
131            .map_err(to_mcp_error)?;
132        tools::describe_workbook(self.state.clone(), params)
133            .await
134            .map(Json)
135            .map_err(to_mcp_error)
136    }
137
138    #[tool(
139        name = "workbook_summary",
140        description = "Summarize workbook regions and entry points"
141    )]
142    pub async fn workbook_summary(
143        &self,
144        Parameters(params): Parameters<tools::WorkbookSummaryParams>,
145    ) -> Result<Json<WorkbookSummaryResponse>, McpError> {
146        self.ensure_tool_enabled("workbook_summary")
147            .map_err(to_mcp_error)?;
148        tools::workbook_summary(self.state.clone(), params)
149            .await
150            .map(Json)
151            .map_err(to_mcp_error)
152    }
153
154    #[tool(name = "list_sheets", description = "List sheets with summaries")]
155    pub async fn list_sheets(
156        &self,
157        Parameters(params): Parameters<tools::ListSheetsParams>,
158    ) -> Result<Json<SheetListResponse>, McpError> {
159        self.ensure_tool_enabled("list_sheets")
160            .map_err(to_mcp_error)?;
161        tools::list_sheets(self.state.clone(), params)
162            .await
163            .map(Json)
164            .map_err(to_mcp_error)
165    }
166
167    #[tool(
168        name = "sheet_overview",
169        description = "Get narrative overview for a sheet"
170    )]
171    pub async fn sheet_overview(
172        &self,
173        Parameters(params): Parameters<tools::SheetOverviewParams>,
174    ) -> Result<Json<SheetOverviewResponse>, McpError> {
175        self.ensure_tool_enabled("sheet_overview")
176            .map_err(to_mcp_error)?;
177        tools::sheet_overview(self.state.clone(), params)
178            .await
179            .map(Json)
180            .map_err(to_mcp_error)
181    }
182
183    #[tool(name = "sheet_page", description = "Page through sheet cells")]
184    pub async fn sheet_page(
185        &self,
186        Parameters(params): Parameters<tools::SheetPageParams>,
187    ) -> Result<Json<SheetPageResponse>, McpError> {
188        self.ensure_tool_enabled("sheet_page")
189            .map_err(to_mcp_error)?;
190        tools::sheet_page(self.state.clone(), params)
191            .await
192            .map(Json)
193            .map_err(to_mcp_error)
194    }
195
196    #[tool(name = "find_value", description = "Search cell values or labels")]
197    pub async fn find_value(
198        &self,
199        Parameters(params): Parameters<tools::FindValueParams>,
200    ) -> Result<Json<FindValueResponse>, McpError> {
201        self.ensure_tool_enabled("find_value")
202            .map_err(to_mcp_error)?;
203        tools::find_value(self.state.clone(), params)
204            .await
205            .map(Json)
206            .map_err(to_mcp_error)
207    }
208
209    #[tool(
210        name = "read_table",
211        description = "Read structured data from a range or table"
212    )]
213    pub async fn read_table(
214        &self,
215        Parameters(params): Parameters<tools::ReadTableParams>,
216    ) -> Result<Json<ReadTableResponse>, McpError> {
217        self.ensure_tool_enabled("read_table")
218            .map_err(to_mcp_error)?;
219        tools::read_table(self.state.clone(), params)
220            .await
221            .map(Json)
222            .map_err(to_mcp_error)
223    }
224
225    #[tool(name = "table_profile", description = "Profile a region or table")]
226    pub async fn table_profile(
227        &self,
228        Parameters(params): Parameters<tools::TableProfileParams>,
229    ) -> Result<Json<TableProfileResponse>, McpError> {
230        self.ensure_tool_enabled("table_profile")
231            .map_err(to_mcp_error)?;
232        tools::table_profile(self.state.clone(), params)
233            .await
234            .map(Json)
235            .map_err(to_mcp_error)
236    }
237
238    #[tool(
239        name = "range_values",
240        description = "Fetch raw values for specific ranges"
241    )]
242    pub async fn range_values(
243        &self,
244        Parameters(params): Parameters<tools::RangeValuesParams>,
245    ) -> Result<Json<RangeValuesResponse>, McpError> {
246        self.ensure_tool_enabled("range_values")
247            .map_err(to_mcp_error)?;
248        tools::range_values(self.state.clone(), params)
249            .await
250            .map(Json)
251            .map_err(to_mcp_error)
252    }
253
254    #[tool(
255        name = "sheet_statistics",
256        description = "Get aggregated sheet statistics"
257    )]
258    pub async fn sheet_statistics(
259        &self,
260        Parameters(params): Parameters<tools::SheetStatisticsParams>,
261    ) -> Result<Json<SheetStatisticsResponse>, McpError> {
262        self.ensure_tool_enabled("sheet_statistics")
263            .map_err(to_mcp_error)?;
264        tools::sheet_statistics(self.state.clone(), params)
265            .await
266            .map(Json)
267            .map_err(to_mcp_error)
268    }
269
270    #[tool(
271        name = "sheet_formula_map",
272        description = "Summarize formula groups across a sheet"
273    )]
274    pub async fn sheet_formula_map(
275        &self,
276        Parameters(params): Parameters<tools::SheetFormulaMapParams>,
277    ) -> Result<Json<SheetFormulaMapResponse>, McpError> {
278        self.ensure_tool_enabled("sheet_formula_map")
279            .map_err(to_mcp_error)?;
280        tools::sheet_formula_map(self.state.clone(), params)
281            .await
282            .map(Json)
283            .map_err(to_mcp_error)
284    }
285
286    #[tool(
287        name = "formula_trace",
288        description = "Trace formula precedents or dependents"
289    )]
290    pub async fn formula_trace(
291        &self,
292        Parameters(params): Parameters<tools::FormulaTraceParams>,
293    ) -> Result<Json<FormulaTraceResponse>, McpError> {
294        self.ensure_tool_enabled("formula_trace")
295            .map_err(to_mcp_error)?;
296        tools::formula_trace(self.state.clone(), params)
297            .await
298            .map(Json)
299            .map_err(to_mcp_error)
300    }
301
302    #[tool(name = "named_ranges", description = "List named ranges and tables")]
303    pub async fn named_ranges(
304        &self,
305        Parameters(params): Parameters<tools::NamedRangesParams>,
306    ) -> Result<Json<NamedRangesResponse>, McpError> {
307        self.ensure_tool_enabled("named_ranges")
308            .map_err(to_mcp_error)?;
309        tools::named_ranges(self.state.clone(), params)
310            .await
311            .map(Json)
312            .map_err(to_mcp_error)
313    }
314
315    #[tool(name = "find_formula", description = "Search formulas containing text")]
316    pub async fn find_formula(
317        &self,
318        Parameters(params): Parameters<tools::FindFormulaParams>,
319    ) -> Result<Json<FindFormulaResponse>, McpError> {
320        self.ensure_tool_enabled("find_formula")
321            .map_err(to_mcp_error)?;
322        tools::find_formula(self.state.clone(), params)
323            .await
324            .map(Json)
325            .map_err(to_mcp_error)
326    }
327
328    #[tool(name = "scan_volatiles", description = "Scan for volatile formulas")]
329    pub async fn scan_volatiles(
330        &self,
331        Parameters(params): Parameters<tools::ScanVolatilesParams>,
332    ) -> Result<Json<VolatileScanResponse>, McpError> {
333        self.ensure_tool_enabled("scan_volatiles")
334            .map_err(to_mcp_error)?;
335        tools::scan_volatiles(self.state.clone(), params)
336            .await
337            .map(Json)
338            .map_err(to_mcp_error)
339    }
340
341    #[tool(
342        name = "sheet_styles",
343        description = "Summarise style usage for a sheet"
344    )]
345    pub async fn sheet_styles(
346        &self,
347        Parameters(params): Parameters<tools::SheetStylesParams>,
348    ) -> Result<Json<SheetStylesResponse>, McpError> {
349        self.ensure_tool_enabled("sheet_styles")
350            .map_err(to_mcp_error)?;
351        tools::sheet_styles(self.state.clone(), params)
352            .await
353            .map(Json)
354            .map_err(to_mcp_error)
355    }
356
357    #[tool(
358        name = "get_manifest_stub",
359        description = "Generate manifest scaffold for workbook"
360    )]
361    pub async fn get_manifest_stub(
362        &self,
363        Parameters(params): Parameters<tools::ManifestStubParams>,
364    ) -> Result<Json<ManifestStubResponse>, McpError> {
365        self.ensure_tool_enabled("get_manifest_stub")
366            .map_err(to_mcp_error)?;
367        tools::get_manifest_stub(self.state.clone(), params)
368            .await
369            .map(Json)
370            .map_err(to_mcp_error)
371    }
372
373    #[tool(name = "close_workbook", description = "Evict a workbook from cache")]
374    pub async fn close_workbook(
375        &self,
376        Parameters(params): Parameters<tools::CloseWorkbookParams>,
377    ) -> Result<Json<CloseWorkbookResponse>, McpError> {
378        self.ensure_tool_enabled("close_workbook")
379            .map_err(to_mcp_error)?;
380        tools::close_workbook(self.state.clone(), params)
381            .await
382            .map(Json)
383            .map_err(to_mcp_error)
384    }
385}
386
387#[cfg(feature = "recalc")]
388#[tool_router(router = fork_tool_router)]
389impl SpreadsheetServer {
390    #[tool(
391        name = "create_fork",
392        description = "Create a temporary editable copy of a workbook for what-if analysis"
393    )]
394    pub async fn create_fork(
395        &self,
396        Parameters(params): Parameters<tools::fork::CreateForkParams>,
397    ) -> Result<Json<tools::fork::CreateForkResponse>, McpError> {
398        self.ensure_recalc_enabled("create_fork")
399            .map_err(to_mcp_error)?;
400        tools::fork::create_fork(self.state.clone(), params)
401            .await
402            .map(Json)
403            .map_err(to_mcp_error)
404    }
405
406    #[tool(
407        name = "edit_batch",
408        description = "Apply batch edits (values or formulas) to a fork"
409    )]
410    pub async fn edit_batch(
411        &self,
412        Parameters(params): Parameters<tools::fork::EditBatchParams>,
413    ) -> Result<Json<tools::fork::EditBatchResponse>, McpError> {
414        self.ensure_recalc_enabled("edit_batch")
415            .map_err(to_mcp_error)?;
416        tools::fork::edit_batch(self.state.clone(), params)
417            .await
418            .map(Json)
419            .map_err(to_mcp_error)
420    }
421
422    #[tool(name = "get_edits", description = "List all edits applied to a fork")]
423    pub async fn get_edits(
424        &self,
425        Parameters(params): Parameters<tools::fork::GetEditsParams>,
426    ) -> Result<Json<tools::fork::GetEditsResponse>, McpError> {
427        self.ensure_recalc_enabled("get_edits")
428            .map_err(to_mcp_error)?;
429        tools::fork::get_edits(self.state.clone(), params)
430            .await
431            .map(Json)
432            .map_err(to_mcp_error)
433    }
434
435    #[tool(
436        name = "get_changeset",
437        description = "Calculate diff between fork and base workbook"
438    )]
439    pub async fn get_changeset(
440        &self,
441        Parameters(params): Parameters<tools::fork::GetChangesetParams>,
442    ) -> Result<Json<tools::fork::GetChangesetResponse>, McpError> {
443        self.ensure_recalc_enabled("get_changeset")
444            .map_err(to_mcp_error)?;
445        tools::fork::get_changeset(self.state.clone(), params)
446            .await
447            .map(Json)
448            .map_err(to_mcp_error)
449    }
450
451    #[tool(
452        name = "recalculate",
453        description = "Recalculate all formulas in a fork using LibreOffice"
454    )]
455    pub async fn recalculate(
456        &self,
457        Parameters(params): Parameters<tools::fork::RecalculateParams>,
458    ) -> Result<Json<tools::fork::RecalculateResponse>, McpError> {
459        self.ensure_recalc_enabled("recalculate")
460            .map_err(to_mcp_error)?;
461        tools::fork::recalculate(self.state.clone(), params)
462            .await
463            .map(Json)
464            .map_err(to_mcp_error)
465    }
466
467    #[tool(name = "list_forks", description = "List all active forks")]
468    pub async fn list_forks(
469        &self,
470        Parameters(params): Parameters<tools::fork::ListForksParams>,
471    ) -> Result<Json<tools::fork::ListForksResponse>, McpError> {
472        self.ensure_recalc_enabled("list_forks")
473            .map_err(to_mcp_error)?;
474        tools::fork::list_forks(self.state.clone(), params)
475            .await
476            .map(Json)
477            .map_err(to_mcp_error)
478    }
479
480    #[tool(name = "discard_fork", description = "Discard a fork without saving")]
481    pub async fn discard_fork(
482        &self,
483        Parameters(params): Parameters<tools::fork::DiscardForkParams>,
484    ) -> Result<Json<tools::fork::DiscardForkResponse>, McpError> {
485        self.ensure_recalc_enabled("discard_fork")
486            .map_err(to_mcp_error)?;
487        tools::fork::discard_fork(self.state.clone(), params)
488            .await
489            .map(Json)
490            .map_err(to_mcp_error)
491    }
492
493    #[tool(
494        name = "save_fork",
495        description = "Save fork changes to target path (defaults to overwriting original)"
496    )]
497    pub async fn save_fork(
498        &self,
499        Parameters(params): Parameters<tools::fork::SaveForkParams>,
500    ) -> Result<Json<tools::fork::SaveForkResponse>, McpError> {
501        self.ensure_recalc_enabled("save_fork")
502            .map_err(to_mcp_error)?;
503        tools::fork::save_fork(self.state.clone(), params)
504            .await
505            .map(Json)
506            .map_err(to_mcp_error)
507    }
508}
509
510#[tool_handler(router = self.tool_router)]
511impl ServerHandler for SpreadsheetServer {
512    fn get_info(&self) -> ServerInfo {
513        ServerInfo {
514            capabilities: ServerCapabilities::builder().enable_tools().build(),
515            server_info: Implementation::from_build_env(),
516            instructions: Some(SERVER_INSTRUCTIONS.to_string()),
517            ..ServerInfo::default()
518        }
519    }
520}
521
522fn to_mcp_error(error: anyhow::Error) -> McpError {
523    if error.downcast_ref::<ToolDisabledError>().is_some() {
524        McpError::invalid_request(error.to_string(), None)
525    } else {
526        McpError::internal_error(error.to_string(), None)
527    }
528}
529
530#[derive(Debug, Error)]
531#[error("tool '{tool_name}' is disabled by server configuration")]
532struct ToolDisabledError {
533    tool_name: String,
534}
535
536impl ToolDisabledError {
537    fn new(tool_name: &str) -> Self {
538        Self {
539            tool_name: tool_name.to_ascii_lowercase(),
540        }
541    }
542}
543
544#[cfg(feature = "recalc")]
545#[derive(Debug, Error)]
546#[error("recalc/write tools are disabled (set SPREADSHEET_MCP_RECALC_ENABLED=true)")]
547struct RecalcDisabledError;