spreadsheet_mcp/
config.rs

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}