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