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}