1use anyhow::{Context, Result};
2use clap::{Parser, ValueEnum};
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::fs;
6use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8
9const DEFAULT_CACHE_CAPACITY: usize = 5;
10const DEFAULT_MAX_RECALCS: usize = 2;
11const DEFAULT_EXTENSIONS: &[&str] = &["xlsx", "xls", "xlsb"];
12const DEFAULT_HTTP_BIND: &str = "127.0.0.1:8079";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum TransportKind {
17 #[value(alias = "stream-http", alias = "stream_http")]
18 #[serde(alias = "stream-http", alias = "stream_http")]
19 Http,
20 Stdio,
21}
22
23impl std::fmt::Display for TransportKind {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 TransportKind::Http => write!(f, "http"),
27 TransportKind::Stdio => write!(f, "stdio"),
28 }
29 }
30}
31
32#[derive(Debug, Clone)]
33pub struct ServerConfig {
34 pub workspace_root: PathBuf,
35 pub cache_capacity: usize,
36 pub supported_extensions: Vec<String>,
37 pub single_workbook: Option<PathBuf>,
38 pub enabled_tools: Option<HashSet<String>>,
39 pub transport: TransportKind,
40 pub http_bind_address: SocketAddr,
41 pub recalc_enabled: bool,
42 pub max_concurrent_recalcs: usize,
43 pub allow_overwrite: bool,
44}
45
46impl ServerConfig {
47 pub fn from_args(args: CliArgs) -> Result<Self> {
48 let CliArgs {
49 config,
50 workspace_root: cli_workspace_root,
51 cache_capacity: cli_cache_capacity,
52 extensions: cli_extensions,
53 workbook: cli_single_workbook,
54 enabled_tools: cli_enabled_tools,
55 transport: cli_transport,
56 http_bind: cli_http_bind,
57 recalc_enabled: cli_recalc_enabled,
58 max_concurrent_recalcs: cli_max_concurrent_recalcs,
59 allow_overwrite: cli_allow_overwrite,
60 } = args;
61
62 let file_config = if let Some(path) = config.as_ref() {
63 load_config_file(path)?
64 } else {
65 PartialConfig::default()
66 };
67
68 let PartialConfig {
69 workspace_root: file_workspace_root,
70 cache_capacity: file_cache_capacity,
71 extensions: file_extensions,
72 single_workbook: file_single_workbook,
73 enabled_tools: file_enabled_tools,
74 transport: file_transport,
75 http_bind: file_http_bind,
76 recalc_enabled: file_recalc_enabled,
77 max_concurrent_recalcs: file_max_concurrent_recalcs,
78 allow_overwrite: file_allow_overwrite,
79 } = file_config;
80
81 let single_workbook = cli_single_workbook.or(file_single_workbook);
82
83 let workspace_root = cli_workspace_root
84 .or(file_workspace_root)
85 .or_else(|| {
86 single_workbook.as_ref().and_then(|path| {
87 if path.is_absolute() {
88 path.parent().map(|parent| parent.to_path_buf())
89 } else {
90 None
91 }
92 })
93 })
94 .unwrap_or_else(|| PathBuf::from("."));
95
96 let cache_capacity = cli_cache_capacity
97 .or(file_cache_capacity)
98 .unwrap_or(DEFAULT_CACHE_CAPACITY)
99 .max(1);
100
101 let mut supported_extensions = cli_extensions
102 .or(file_extensions)
103 .unwrap_or_else(|| {
104 DEFAULT_EXTENSIONS
105 .iter()
106 .map(|ext| (*ext).to_string())
107 .collect()
108 })
109 .into_iter()
110 .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
111 .filter(|ext| !ext.is_empty())
112 .collect::<Vec<_>>();
113
114 supported_extensions.sort();
115 supported_extensions.dedup();
116
117 anyhow::ensure!(
118 !supported_extensions.is_empty(),
119 "at least one file extension must be provided"
120 );
121
122 let single_workbook = single_workbook.map(|path| {
123 if path.is_absolute() {
124 path
125 } else {
126 workspace_root.join(path)
127 }
128 });
129
130 if let Some(workbook_path) = single_workbook.as_ref() {
131 anyhow::ensure!(
132 workbook_path.exists(),
133 "configured workbook {:?} does not exist",
134 workbook_path
135 );
136 anyhow::ensure!(
137 workbook_path.is_file(),
138 "configured workbook {:?} is not a file",
139 workbook_path
140 );
141 let allowed = workbook_path
142 .extension()
143 .and_then(|ext| ext.to_str())
144 .map(|ext| ext.to_ascii_lowercase())
145 .map(|ext| supported_extensions.contains(&ext))
146 .unwrap_or(false);
147 anyhow::ensure!(
148 allowed,
149 "configured workbook {:?} does not match allowed extensions {:?}",
150 workbook_path,
151 supported_extensions
152 );
153 }
154
155 let enabled_tools = cli_enabled_tools
156 .or(file_enabled_tools)
157 .map(|tools| {
158 tools
159 .into_iter()
160 .map(|tool| tool.to_ascii_lowercase())
161 .filter(|tool| !tool.is_empty())
162 .collect::<HashSet<_>>()
163 })
164 .filter(|set| !set.is_empty());
165
166 let transport = cli_transport
167 .or(file_transport)
168 .unwrap_or(TransportKind::Http);
169
170 let http_bind_address = cli_http_bind.or(file_http_bind).unwrap_or_else(|| {
171 DEFAULT_HTTP_BIND
172 .parse()
173 .expect("default bind address valid")
174 });
175
176 let recalc_enabled = cli_recalc_enabled || file_recalc_enabled.unwrap_or(false);
177
178 let max_concurrent_recalcs = cli_max_concurrent_recalcs
179 .or(file_max_concurrent_recalcs)
180 .unwrap_or(DEFAULT_MAX_RECALCS)
181 .max(1);
182
183 let allow_overwrite = cli_allow_overwrite || file_allow_overwrite.unwrap_or(false);
184
185 Ok(Self {
186 workspace_root,
187 cache_capacity,
188 supported_extensions,
189 single_workbook,
190 enabled_tools,
191 transport,
192 http_bind_address,
193 recalc_enabled,
194 max_concurrent_recalcs,
195 allow_overwrite,
196 })
197 }
198
199 pub fn ensure_workspace_root(&self) -> Result<()> {
200 anyhow::ensure!(
201 self.workspace_root.exists(),
202 "workspace root {:?} does not exist",
203 self.workspace_root
204 );
205 anyhow::ensure!(
206 self.workspace_root.is_dir(),
207 "workspace root {:?} is not a directory",
208 self.workspace_root
209 );
210 if let Some(workbook) = self.single_workbook.as_ref() {
211 anyhow::ensure!(
212 workbook.exists(),
213 "configured workbook {:?} does not exist",
214 workbook
215 );
216 anyhow::ensure!(
217 workbook.is_file(),
218 "configured workbook {:?} is not a file",
219 workbook
220 );
221 }
222 Ok(())
223 }
224
225 pub fn resolve_path<P: AsRef<Path>>(&self, relative: P) -> PathBuf {
226 let relative = relative.as_ref();
227 if relative.is_absolute() {
228 relative.to_path_buf()
229 } else {
230 self.workspace_root.join(relative)
231 }
232 }
233
234 pub fn single_workbook(&self) -> Option<&Path> {
235 self.single_workbook.as_deref()
236 }
237
238 pub fn is_tool_enabled(&self, tool: &str) -> bool {
239 match &self.enabled_tools {
240 Some(set) => set.contains(&tool.to_ascii_lowercase()),
241 None => true,
242 }
243 }
244}
245
246#[derive(Parser, Debug, Default, Clone)]
247#[command(name = "spreadsheet-mcp", about = "Spreadsheet MCP server", version)]
248pub struct CliArgs {
249 #[arg(
250 long,
251 value_name = "FILE",
252 help = "Path to a configuration file (YAML or JSON)",
253 global = true
254 )]
255 pub config: Option<PathBuf>,
256
257 #[arg(
258 long,
259 env = "SPREADSHEET_MCP_WORKSPACE",
260 value_name = "DIR",
261 help = "Workspace root containing spreadsheet files"
262 )]
263 pub workspace_root: Option<PathBuf>,
264
265 #[arg(
266 long,
267 env = "SPREADSHEET_MCP_CACHE_CAPACITY",
268 value_name = "N",
269 help = "Maximum number of workbooks kept in memory",
270 value_parser = clap::value_parser!(usize)
271 )]
272 pub cache_capacity: Option<usize>,
273
274 #[arg(
275 long,
276 env = "SPREADSHEET_MCP_EXTENSIONS",
277 value_name = "EXT",
278 value_delimiter = ',',
279 help = "Comma-separated list of allowed workbook extensions"
280 )]
281 pub extensions: Option<Vec<String>>,
282
283 #[arg(
284 long,
285 env = "SPREADSHEET_MCP_WORKBOOK",
286 value_name = "FILE",
287 help = "Lock the server to a single workbook path"
288 )]
289 pub workbook: Option<PathBuf>,
290
291 #[arg(
292 long,
293 env = "SPREADSHEET_MCP_ENABLED_TOOLS",
294 value_name = "TOOL",
295 value_delimiter = ',',
296 help = "Restrict execution to the provided tool names"
297 )]
298 pub enabled_tools: Option<Vec<String>>,
299
300 #[arg(
301 long,
302 env = "SPREADSHEET_MCP_TRANSPORT",
303 value_enum,
304 value_name = "TRANSPORT",
305 help = "Transport to expose (http or stdio)"
306 )]
307 pub transport: Option<TransportKind>,
308
309 #[arg(
310 long,
311 env = "SPREADSHEET_MCP_HTTP_BIND",
312 value_name = "ADDR",
313 help = "HTTP bind address when using http transport"
314 )]
315 pub http_bind: Option<SocketAddr>,
316
317 #[arg(
318 long,
319 env = "SPREADSHEET_MCP_RECALC_ENABLED",
320 help = "Enable write/recalc tools (requires LibreOffice)"
321 )]
322 pub recalc_enabled: bool,
323
324 #[arg(
325 long,
326 env = "SPREADSHEET_MCP_MAX_CONCURRENT_RECALCS",
327 help = "Max concurrent LibreOffice instances"
328 )]
329 pub max_concurrent_recalcs: Option<usize>,
330
331 #[arg(
332 long,
333 env = "SPREADSHEET_MCP_ALLOW_OVERWRITE",
334 help = "Allow save_fork to overwrite original workbook files"
335 )]
336 pub allow_overwrite: bool,
337}
338
339#[derive(Debug, Default, Deserialize)]
340struct PartialConfig {
341 workspace_root: Option<PathBuf>,
342 cache_capacity: Option<usize>,
343 extensions: Option<Vec<String>>,
344 single_workbook: Option<PathBuf>,
345 enabled_tools: Option<Vec<String>>,
346 transport: Option<TransportKind>,
347 http_bind: Option<SocketAddr>,
348 recalc_enabled: Option<bool>,
349 max_concurrent_recalcs: Option<usize>,
350 allow_overwrite: Option<bool>,
351}
352
353fn load_config_file(path: &Path) -> Result<PartialConfig> {
354 if !path.exists() {
355 anyhow::bail!("config file {:?} does not exist", path);
356 }
357 let contents = fs::read_to_string(path)
358 .with_context(|| format!("failed to read config file {:?}", path))?;
359 let ext = path
360 .extension()
361 .and_then(|os| os.to_str())
362 .unwrap_or("")
363 .to_ascii_lowercase();
364
365 let parsed = match ext.as_str() {
366 "yaml" | "yml" => serde_yaml::from_str(&contents)
367 .with_context(|| format!("failed to parse YAML config {:?}", path))?,
368 "json" => serde_json::from_str(&contents)
369 .with_context(|| format!("failed to parse JSON config {:?}", path))?,
370 other => anyhow::bail!("unsupported config extension: {other}"),
371 };
372 Ok(parsed)
373}