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;