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