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}