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;