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}
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}