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;