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}