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}