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