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