spreadsheet_read_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        Self {
61            state,
62            tool_router: Self::tool_router(),
63        }
64    }
65
66    pub async fn run_stdio(self) -> Result<()> {
67        let service = self
68            .serve(stdio())
69            .await
70            .inspect_err(|error| tracing::error!("serving error: {:?}", error))?;
71        service.waiting().await?;
72        Ok(())
73    }
74
75    pub async fn run(self) -> Result<()> {
76        self.run_stdio().await
77    }
78
79    fn ensure_tool_enabled(&self, tool: &str) -> Result<()> {
80        tracing::info!(tool = tool, "tool invocation requested");
81        if self.state.config().is_tool_enabled(tool) {
82            Ok(())
83        } else {
84            Err(ToolDisabledError::new(tool).into())
85        }
86    }
87}
88
89#[tool_router]
90impl SpreadsheetServer {
91    #[tool(
92        name = "list_workbooks",
93        description = "List spreadsheet files in the workspace"
94    )]
95    pub async fn list_workbooks(
96        &self,
97        Parameters(params): Parameters<tools::ListWorkbooksParams>,
98    ) -> Result<Json<WorkbookListResponse>, McpError> {
99        self.ensure_tool_enabled("list_workbooks")
100            .map_err(to_mcp_error)?;
101        tools::list_workbooks(self.state.clone(), params)
102            .await
103            .map(Json)
104            .map_err(to_mcp_error)
105    }
106
107    #[tool(name = "describe_workbook", description = "Describe workbook metadata")]
108    pub async fn describe_workbook(
109        &self,
110        Parameters(params): Parameters<tools::DescribeWorkbookParams>,
111    ) -> Result<Json<WorkbookDescription>, McpError> {
112        self.ensure_tool_enabled("describe_workbook")
113            .map_err(to_mcp_error)?;
114        tools::describe_workbook(self.state.clone(), params)
115            .await
116            .map(Json)
117            .map_err(to_mcp_error)
118    }
119
120    #[tool(
121        name = "workbook_summary",
122        description = "Summarize workbook regions and entry points"
123    )]
124    pub async fn workbook_summary(
125        &self,
126        Parameters(params): Parameters<tools::WorkbookSummaryParams>,
127    ) -> Result<Json<WorkbookSummaryResponse>, McpError> {
128        self.ensure_tool_enabled("workbook_summary")
129            .map_err(to_mcp_error)?;
130        tools::workbook_summary(self.state.clone(), params)
131            .await
132            .map(Json)
133            .map_err(to_mcp_error)
134    }
135
136    #[tool(name = "list_sheets", description = "List sheets with summaries")]
137    pub async fn list_sheets(
138        &self,
139        Parameters(params): Parameters<tools::ListSheetsParams>,
140    ) -> Result<Json<SheetListResponse>, McpError> {
141        self.ensure_tool_enabled("list_sheets")
142            .map_err(to_mcp_error)?;
143        tools::list_sheets(self.state.clone(), params)
144            .await
145            .map(Json)
146            .map_err(to_mcp_error)
147    }
148
149    #[tool(
150        name = "sheet_overview",
151        description = "Get narrative overview for a sheet"
152    )]
153    pub async fn sheet_overview(
154        &self,
155        Parameters(params): Parameters<tools::SheetOverviewParams>,
156    ) -> Result<Json<SheetOverviewResponse>, McpError> {
157        self.ensure_tool_enabled("sheet_overview")
158            .map_err(to_mcp_error)?;
159        tools::sheet_overview(self.state.clone(), params)
160            .await
161            .map(Json)
162            .map_err(to_mcp_error)
163    }
164
165    #[tool(name = "sheet_page", description = "Page through sheet cells")]
166    pub async fn sheet_page(
167        &self,
168        Parameters(params): Parameters<tools::SheetPageParams>,
169    ) -> Result<Json<SheetPageResponse>, McpError> {
170        self.ensure_tool_enabled("sheet_page")
171            .map_err(to_mcp_error)?;
172        tools::sheet_page(self.state.clone(), params)
173            .await
174            .map(Json)
175            .map_err(to_mcp_error)
176    }
177
178    #[tool(name = "find_value", description = "Search cell values or labels")]
179    pub async fn find_value(
180        &self,
181        Parameters(params): Parameters<tools::FindValueParams>,
182    ) -> Result<Json<FindValueResponse>, McpError> {
183        self.ensure_tool_enabled("find_value")
184            .map_err(to_mcp_error)?;
185        tools::find_value(self.state.clone(), params)
186            .await
187            .map(Json)
188            .map_err(to_mcp_error)
189    }
190
191    #[tool(
192        name = "read_table",
193        description = "Read structured data from a range or table"
194    )]
195    pub async fn read_table(
196        &self,
197        Parameters(params): Parameters<tools::ReadTableParams>,
198    ) -> Result<Json<ReadTableResponse>, McpError> {
199        self.ensure_tool_enabled("read_table")
200            .map_err(to_mcp_error)?;
201        tools::read_table(self.state.clone(), params)
202            .await
203            .map(Json)
204            .map_err(to_mcp_error)
205    }
206
207    #[tool(name = "table_profile", description = "Profile a region or table")]
208    pub async fn table_profile(
209        &self,
210        Parameters(params): Parameters<tools::TableProfileParams>,
211    ) -> Result<Json<TableProfileResponse>, McpError> {
212        self.ensure_tool_enabled("table_profile")
213            .map_err(to_mcp_error)?;
214        tools::table_profile(self.state.clone(), params)
215            .await
216            .map(Json)
217            .map_err(to_mcp_error)
218    }
219
220    #[tool(
221        name = "range_values",
222        description = "Fetch raw values for specific ranges"
223    )]
224    pub async fn range_values(
225        &self,
226        Parameters(params): Parameters<tools::RangeValuesParams>,
227    ) -> Result<Json<RangeValuesResponse>, McpError> {
228        self.ensure_tool_enabled("range_values")
229            .map_err(to_mcp_error)?;
230        tools::range_values(self.state.clone(), params)
231            .await
232            .map(Json)
233            .map_err(to_mcp_error)
234    }
235
236    #[tool(
237        name = "sheet_statistics",
238        description = "Get aggregated sheet statistics"
239    )]
240    pub async fn sheet_statistics(
241        &self,
242        Parameters(params): Parameters<tools::SheetStatisticsParams>,
243    ) -> Result<Json<SheetStatisticsResponse>, McpError> {
244        self.ensure_tool_enabled("sheet_statistics")
245            .map_err(to_mcp_error)?;
246        tools::sheet_statistics(self.state.clone(), params)
247            .await
248            .map(Json)
249            .map_err(to_mcp_error)
250    }
251
252    #[tool(
253        name = "sheet_formula_map",
254        description = "Summarize formula groups across a sheet"
255    )]
256    pub async fn sheet_formula_map(
257        &self,
258        Parameters(params): Parameters<tools::SheetFormulaMapParams>,
259    ) -> Result<Json<SheetFormulaMapResponse>, McpError> {
260        self.ensure_tool_enabled("sheet_formula_map")
261            .map_err(to_mcp_error)?;
262        tools::sheet_formula_map(self.state.clone(), params)
263            .await
264            .map(Json)
265            .map_err(to_mcp_error)
266    }
267
268    #[tool(
269        name = "formula_trace",
270        description = "Trace formula precedents or dependents"
271    )]
272    pub async fn formula_trace(
273        &self,
274        Parameters(params): Parameters<tools::FormulaTraceParams>,
275    ) -> Result<Json<FormulaTraceResponse>, McpError> {
276        self.ensure_tool_enabled("formula_trace")
277            .map_err(to_mcp_error)?;
278        tools::formula_trace(self.state.clone(), params)
279            .await
280            .map(Json)
281            .map_err(to_mcp_error)
282    }
283
284    #[tool(name = "named_ranges", description = "List named ranges and tables")]
285    pub async fn named_ranges(
286        &self,
287        Parameters(params): Parameters<tools::NamedRangesParams>,
288    ) -> Result<Json<NamedRangesResponse>, McpError> {
289        self.ensure_tool_enabled("named_ranges")
290            .map_err(to_mcp_error)?;
291        tools::named_ranges(self.state.clone(), params)
292            .await
293            .map(Json)
294            .map_err(to_mcp_error)
295    }
296
297    #[tool(name = "find_formula", description = "Search formulas containing text")]
298    pub async fn find_formula(
299        &self,
300        Parameters(params): Parameters<tools::FindFormulaParams>,
301    ) -> Result<Json<FindFormulaResponse>, McpError> {
302        self.ensure_tool_enabled("find_formula")
303            .map_err(to_mcp_error)?;
304        tools::find_formula(self.state.clone(), params)
305            .await
306            .map(Json)
307            .map_err(to_mcp_error)
308    }
309
310    #[tool(name = "scan_volatiles", description = "Scan for volatile formulas")]
311    pub async fn scan_volatiles(
312        &self,
313        Parameters(params): Parameters<tools::ScanVolatilesParams>,
314    ) -> Result<Json<VolatileScanResponse>, McpError> {
315        self.ensure_tool_enabled("scan_volatiles")
316            .map_err(to_mcp_error)?;
317        tools::scan_volatiles(self.state.clone(), params)
318            .await
319            .map(Json)
320            .map_err(to_mcp_error)
321    }
322
323    #[tool(
324        name = "sheet_styles",
325        description = "Summarise style usage for a sheet"
326    )]
327    pub async fn sheet_styles(
328        &self,
329        Parameters(params): Parameters<tools::SheetStylesParams>,
330    ) -> Result<Json<SheetStylesResponse>, McpError> {
331        self.ensure_tool_enabled("sheet_styles")
332            .map_err(to_mcp_error)?;
333        tools::sheet_styles(self.state.clone(), params)
334            .await
335            .map(Json)
336            .map_err(to_mcp_error)
337    }
338
339    #[tool(
340        name = "get_manifest_stub",
341        description = "Generate manifest scaffold for workbook"
342    )]
343    pub async fn get_manifest_stub(
344        &self,
345        Parameters(params): Parameters<tools::ManifestStubParams>,
346    ) -> Result<Json<ManifestStubResponse>, McpError> {
347        self.ensure_tool_enabled("get_manifest_stub")
348            .map_err(to_mcp_error)?;
349        tools::get_manifest_stub(self.state.clone(), params)
350            .await
351            .map(Json)
352            .map_err(to_mcp_error)
353    }
354
355    #[tool(name = "close_workbook", description = "Evict a workbook from cache")]
356    pub async fn close_workbook(
357        &self,
358        Parameters(params): Parameters<tools::CloseWorkbookParams>,
359    ) -> Result<Json<CloseWorkbookResponse>, McpError> {
360        self.ensure_tool_enabled("close_workbook")
361            .map_err(to_mcp_error)?;
362        tools::close_workbook(self.state.clone(), params)
363            .await
364            .map(Json)
365            .map_err(to_mcp_error)
366    }
367}
368
369#[tool_handler(router = self.tool_router)]
370impl ServerHandler for SpreadsheetServer {
371    fn get_info(&self) -> ServerInfo {
372        ServerInfo {
373            capabilities: ServerCapabilities::builder().enable_tools().build(),
374            server_info: Implementation::from_build_env(),
375            instructions: Some(SERVER_INSTRUCTIONS.to_string()),
376            ..ServerInfo::default()
377        }
378    }
379}
380
381fn to_mcp_error(error: anyhow::Error) -> McpError {
382    if error.downcast_ref::<ToolDisabledError>().is_some() {
383        McpError::invalid_request(error.to_string(), None)
384    } else {
385        McpError::internal_error(error.to_string(), None)
386    }
387}
388
389#[derive(Debug, Error)]
390#[error("tool '{tool_name}' is disabled by server configuration")]
391struct ToolDisabledError {
392    tool_name: String,
393}
394
395impl ToolDisabledError {
396    fn new(tool_name: &str) -> Self {
397        Self {
398            tool_name: tool_name.to_ascii_lowercase(),
399        }
400    }
401}