spreadsheet_read_mcp/
server.rs

1use crate::config::ServerConfig;
2use crate::model::{
3    CloseWorkbookResponse, FindFormulaResponse, FormulaTraceResponse, ManifestStubResponse,
4    NamedRangesResponse, SheetFormulaMapResponse, SheetListResponse, SheetOverviewResponse,
5    SheetPageResponse, SheetStatisticsResponse, SheetStylesResponse, VolatileScanResponse,
6    WorkbookDescription, WorkbookListResponse,
7};
8use crate::state::AppState;
9use crate::tools;
10use anyhow::Result;
11use rmcp::{
12    ErrorData as McpError, Json, ServerHandler, ServiceExt,
13    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
14    model::{Implementation, ServerCapabilities, ServerInfo},
15    tool, tool_handler, tool_router,
16    transport::stdio,
17};
18use std::sync::Arc;
19use thiserror::Error;
20
21#[derive(Clone)]
22pub struct SpreadsheetServer {
23    state: Arc<AppState>,
24    tool_router: ToolRouter<SpreadsheetServer>,
25}
26
27impl SpreadsheetServer {
28    pub async fn new(config: Arc<ServerConfig>) -> Result<Self> {
29        config.ensure_workspace_root()?;
30        let state = Arc::new(AppState::new(config));
31        Ok(Self::from_state(state))
32    }
33
34    pub fn from_state(state: Arc<AppState>) -> Self {
35        Self {
36            state,
37            tool_router: Self::tool_router(),
38        }
39    }
40
41    pub async fn run_stdio(self) -> Result<()> {
42        let service = self
43            .serve(stdio())
44            .await
45            .inspect_err(|error| tracing::error!("serving error: {:?}", error))?;
46        service.waiting().await?;
47        Ok(())
48    }
49
50    pub async fn run(self) -> Result<()> {
51        self.run_stdio().await
52    }
53
54    fn ensure_tool_enabled(&self, tool: &str) -> Result<()> {
55        tracing::info!(tool = tool, "tool invocation requested");
56        if self.state.config().is_tool_enabled(tool) {
57            Ok(())
58        } else {
59            Err(ToolDisabledError::new(tool).into())
60        }
61    }
62}
63
64#[tool_router]
65impl SpreadsheetServer {
66    #[tool(
67        name = "list_workbooks",
68        description = "List spreadsheet files in the workspace"
69    )]
70    pub async fn list_workbooks(
71        &self,
72        Parameters(params): Parameters<tools::ListWorkbooksParams>,
73    ) -> Result<Json<WorkbookListResponse>, McpError> {
74        self.ensure_tool_enabled("list_workbooks")
75            .map_err(to_mcp_error)?;
76        tools::list_workbooks(self.state.clone(), params)
77            .await
78            .map(Json)
79            .map_err(to_mcp_error)
80    }
81
82    #[tool(name = "describe_workbook", description = "Describe workbook metadata")]
83    pub async fn describe_workbook(
84        &self,
85        Parameters(params): Parameters<tools::DescribeWorkbookParams>,
86    ) -> Result<Json<WorkbookDescription>, McpError> {
87        self.ensure_tool_enabled("describe_workbook")
88            .map_err(to_mcp_error)?;
89        tools::describe_workbook(self.state.clone(), params)
90            .await
91            .map(Json)
92            .map_err(to_mcp_error)
93    }
94
95    #[tool(name = "list_sheets", description = "List sheets with summaries")]
96    pub async fn list_sheets(
97        &self,
98        Parameters(params): Parameters<tools::ListSheetsParams>,
99    ) -> Result<Json<SheetListResponse>, McpError> {
100        self.ensure_tool_enabled("list_sheets")
101            .map_err(to_mcp_error)?;
102        tools::list_sheets(self.state.clone(), params)
103            .await
104            .map(Json)
105            .map_err(to_mcp_error)
106    }
107
108    #[tool(
109        name = "sheet_overview",
110        description = "Get narrative overview for a sheet"
111    )]
112    pub async fn sheet_overview(
113        &self,
114        Parameters(params): Parameters<tools::SheetOverviewParams>,
115    ) -> Result<Json<SheetOverviewResponse>, McpError> {
116        self.ensure_tool_enabled("sheet_overview")
117            .map_err(to_mcp_error)?;
118        tools::sheet_overview(self.state.clone(), params)
119            .await
120            .map(Json)
121            .map_err(to_mcp_error)
122    }
123
124    #[tool(name = "sheet_page", description = "Page through sheet cells")]
125    pub async fn sheet_page(
126        &self,
127        Parameters(params): Parameters<tools::SheetPageParams>,
128    ) -> Result<Json<SheetPageResponse>, McpError> {
129        self.ensure_tool_enabled("sheet_page")
130            .map_err(to_mcp_error)?;
131        tools::sheet_page(self.state.clone(), params)
132            .await
133            .map(Json)
134            .map_err(to_mcp_error)
135    }
136
137    #[tool(
138        name = "sheet_statistics",
139        description = "Get aggregated sheet statistics"
140    )]
141    pub async fn sheet_statistics(
142        &self,
143        Parameters(params): Parameters<tools::SheetStatisticsParams>,
144    ) -> Result<Json<SheetStatisticsResponse>, McpError> {
145        self.ensure_tool_enabled("sheet_statistics")
146            .map_err(to_mcp_error)?;
147        tools::sheet_statistics(self.state.clone(), params)
148            .await
149            .map(Json)
150            .map_err(to_mcp_error)
151    }
152
153    #[tool(
154        name = "sheet_formula_map",
155        description = "Summarize formula groups across a sheet"
156    )]
157    pub async fn sheet_formula_map(
158        &self,
159        Parameters(params): Parameters<tools::SheetFormulaMapParams>,
160    ) -> Result<Json<SheetFormulaMapResponse>, McpError> {
161        self.ensure_tool_enabled("sheet_formula_map")
162            .map_err(to_mcp_error)?;
163        tools::sheet_formula_map(self.state.clone(), params)
164            .await
165            .map(Json)
166            .map_err(to_mcp_error)
167    }
168
169    #[tool(
170        name = "formula_trace",
171        description = "Trace formula precedents or dependents"
172    )]
173    pub async fn formula_trace(
174        &self,
175        Parameters(params): Parameters<tools::FormulaTraceParams>,
176    ) -> Result<Json<FormulaTraceResponse>, McpError> {
177        self.ensure_tool_enabled("formula_trace")
178            .map_err(to_mcp_error)?;
179        tools::formula_trace(self.state.clone(), params)
180            .await
181            .map(Json)
182            .map_err(to_mcp_error)
183    }
184
185    #[tool(name = "named_ranges", description = "List named ranges and tables")]
186    pub async fn named_ranges(
187        &self,
188        Parameters(params): Parameters<tools::NamedRangesParams>,
189    ) -> Result<Json<NamedRangesResponse>, McpError> {
190        self.ensure_tool_enabled("named_ranges")
191            .map_err(to_mcp_error)?;
192        tools::named_ranges(self.state.clone(), params)
193            .await
194            .map(Json)
195            .map_err(to_mcp_error)
196    }
197
198    #[tool(name = "find_formula", description = "Search formulas containing text")]
199    pub async fn find_formula(
200        &self,
201        Parameters(params): Parameters<tools::FindFormulaParams>,
202    ) -> Result<Json<FindFormulaResponse>, McpError> {
203        self.ensure_tool_enabled("find_formula")
204            .map_err(to_mcp_error)?;
205        tools::find_formula(self.state.clone(), params)
206            .await
207            .map(Json)
208            .map_err(to_mcp_error)
209    }
210
211    #[tool(name = "scan_volatiles", description = "Scan for volatile formulas")]
212    pub async fn scan_volatiles(
213        &self,
214        Parameters(params): Parameters<tools::ScanVolatilesParams>,
215    ) -> Result<Json<VolatileScanResponse>, McpError> {
216        self.ensure_tool_enabled("scan_volatiles")
217            .map_err(to_mcp_error)?;
218        tools::scan_volatiles(self.state.clone(), params)
219            .await
220            .map(Json)
221            .map_err(to_mcp_error)
222    }
223
224    #[tool(
225        name = "sheet_styles",
226        description = "Summarise style usage for a sheet"
227    )]
228    pub async fn sheet_styles(
229        &self,
230        Parameters(params): Parameters<tools::SheetStylesParams>,
231    ) -> Result<Json<SheetStylesResponse>, McpError> {
232        self.ensure_tool_enabled("sheet_styles")
233            .map_err(to_mcp_error)?;
234        tools::sheet_styles(self.state.clone(), params)
235            .await
236            .map(Json)
237            .map_err(to_mcp_error)
238    }
239
240    #[tool(
241        name = "get_manifest_stub",
242        description = "Generate manifest scaffold for workbook"
243    )]
244    pub async fn get_manifest_stub(
245        &self,
246        Parameters(params): Parameters<tools::ManifestStubParams>,
247    ) -> Result<Json<ManifestStubResponse>, McpError> {
248        self.ensure_tool_enabled("get_manifest_stub")
249            .map_err(to_mcp_error)?;
250        tools::get_manifest_stub(self.state.clone(), params)
251            .await
252            .map(Json)
253            .map_err(to_mcp_error)
254    }
255
256    #[tool(name = "close_workbook", description = "Evict a workbook from cache")]
257    pub async fn close_workbook(
258        &self,
259        Parameters(params): Parameters<tools::CloseWorkbookParams>,
260    ) -> Result<Json<CloseWorkbookResponse>, McpError> {
261        self.ensure_tool_enabled("close_workbook")
262            .map_err(to_mcp_error)?;
263        tools::close_workbook(self.state.clone(), params)
264            .await
265            .map(Json)
266            .map_err(to_mcp_error)
267    }
268}
269
270#[tool_handler(router = self.tool_router)]
271impl ServerHandler for SpreadsheetServer {
272    fn get_info(&self) -> ServerInfo {
273        ServerInfo {
274            capabilities: ServerCapabilities::builder().enable_tools().build(),
275            server_info: Implementation::from_build_env(),
276            instructions: Some(
277                "Spreadsheet MCP server. Use tools to explore workbooks in the configured workspace.".to_string(),
278            ),
279            ..ServerInfo::default()
280        }
281    }
282}
283
284fn to_mcp_error(error: anyhow::Error) -> McpError {
285    if error.downcast_ref::<ToolDisabledError>().is_some() {
286        McpError::invalid_request(error.to_string(), None)
287    } else {
288        McpError::internal_error(error.to_string(), None)
289    }
290}
291
292#[derive(Debug, Error)]
293#[error("tool '{tool_name}' is disabled by server configuration")]
294struct ToolDisabledError {
295    tool_name: String,
296}
297
298impl ToolDisabledError {
299    fn new(tool_name: &str) -> Self {
300        Self {
301            tool_name: tool_name.to_ascii_lowercase(),
302        }
303    }
304}