1use anyhow::{Context, Result};
2use clap::{Parser, ValueEnum};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use std::fs;
7use std::net::SocketAddr;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11const DEFAULT_CACHE_CAPACITY: usize = 5;
12const DEFAULT_MAX_RECALCS: usize = 2;
13const DEFAULT_EXTENSIONS: &[&str] = &["xlsx", "xlsm", "xls", "xlsb"];
14const DEFAULT_HTTP_BIND: &str = "127.0.0.1:8079";
15const DEFAULT_TOOL_TIMEOUT_MS: u64 = 30_000;
16const DEFAULT_MAX_RESPONSE_BYTES: u64 = 1_000_000;
17const DEFAULT_MAX_PAYLOAD_BYTES: u64 = 65_536;
18const DEFAULT_MAX_CELLS: u64 = 10_000;
19const DEFAULT_MAX_ITEMS: u64 = 500;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum TransportKind {
24 #[value(alias = "stream-http", alias = "stream_http")]
25 #[serde(alias = "stream-http", alias = "stream_http")]
26 Http,
27 Stdio,
28}
29
30impl std::fmt::Display for TransportKind {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 match self {
33 TransportKind::Http => write!(f, "http"),
34 TransportKind::Stdio => write!(f, "stdio"),
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize, Default)]
40#[serde(rename_all = "snake_case")]
41pub enum OutputProfile {
42 #[default]
43 TokenDense,
44 Verbose,
45}
46
47#[derive(
48 Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize, JsonSchema, Default,
49)]
50#[serde(rename_all = "lowercase")]
51pub enum RecalcBackendKind {
52 Formualizer,
53 Libreoffice,
54 #[default]
55 Auto,
56}
57
58#[derive(Debug, Clone)]
59pub struct ServerConfig {
60 pub workspace_root: PathBuf,
61 pub screenshot_dir: PathBuf,
63 pub path_mappings: Vec<PathMapping>,
66 pub cache_capacity: usize,
67 pub supported_extensions: Vec<String>,
68 pub single_workbook: Option<PathBuf>,
69 pub enabled_tools: Option<HashSet<String>>,
70 pub transport: TransportKind,
71 pub http_bind_address: SocketAddr,
72 pub recalc_enabled: bool,
73 pub recalc_backend: RecalcBackendKind,
74 pub vba_enabled: bool,
75 pub max_concurrent_recalcs: usize,
76 pub tool_timeout_ms: Option<u64>,
77 pub max_response_bytes: Option<u64>,
78 pub output_profile: OutputProfile,
79 pub max_payload_bytes: Option<u64>,
80 pub max_cells: Option<u64>,
81 pub max_items: Option<u64>,
82 pub allow_overwrite: bool,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PathMapping {
87 pub internal_prefix: PathBuf,
88 pub client_prefix: PathBuf,
89}
90
91impl PathMapping {
92 fn parse(spec: &str) -> Result<Self> {
93 let (internal, client) = spec.split_once('=').ok_or_else(|| {
94 anyhow::anyhow!("invalid path mapping '{spec}' (expected INTERNAL=CLIENT)")
95 })?;
96
97 let internal_prefix = PathBuf::from(internal.trim());
98 let client_prefix = PathBuf::from(client.trim());
99
100 anyhow::ensure!(
101 !internal_prefix.as_os_str().is_empty() && !client_prefix.as_os_str().is_empty(),
102 "invalid path mapping '{spec}' (empty prefix)"
103 );
104
105 Ok(Self {
106 internal_prefix,
107 client_prefix,
108 })
109 }
110}
111
112impl ServerConfig {
113 pub fn from_args(args: CliArgs) -> Result<Self> {
114 let CliArgs {
115 config,
116 workspace_root: cli_workspace_root,
117 screenshot_dir: cli_screenshot_dir,
118 path_map: cli_path_map,
119 cache_capacity: cli_cache_capacity,
120 extensions: cli_extensions,
121 workbook: cli_single_workbook,
122 enabled_tools: cli_enabled_tools,
123 transport: cli_transport,
124 http_bind: cli_http_bind,
125 recalc_enabled: cli_recalc_enabled,
126 recalc_backend: cli_recalc_backend,
127 vba_enabled: cli_vba_enabled,
128 max_concurrent_recalcs: cli_max_concurrent_recalcs,
129 tool_timeout_ms: cli_tool_timeout_ms,
130 max_response_bytes: cli_max_response_bytes,
131 output_profile: cli_output_profile,
132 max_payload_bytes: cli_max_payload_bytes,
133 max_cells: cli_max_cells,
134 max_items: cli_max_items,
135 allow_overwrite: cli_allow_overwrite,
136 } = args;
137
138 let file_config = if let Some(path) = config.as_ref() {
139 load_config_file(path)?
140 } else {
141 PartialConfig::default()
142 };
143
144 let PartialConfig {
145 workspace_root: file_workspace_root,
146 screenshot_dir: file_screenshot_dir,
147 path_map: file_path_map,
148 cache_capacity: file_cache_capacity,
149 extensions: file_extensions,
150 single_workbook: file_single_workbook,
151 enabled_tools: file_enabled_tools,
152 transport: file_transport,
153 http_bind: file_http_bind,
154 recalc_enabled: file_recalc_enabled,
155 recalc_backend: file_recalc_backend,
156 vba_enabled: file_vba_enabled,
157 max_concurrent_recalcs: file_max_concurrent_recalcs,
158 tool_timeout_ms: file_tool_timeout_ms,
159 max_response_bytes: file_max_response_bytes,
160 output_profile: file_output_profile,
161 max_payload_bytes: file_max_payload_bytes,
162 max_cells: file_max_cells,
163 max_items: file_max_items,
164 allow_overwrite: file_allow_overwrite,
165 } = file_config;
166
167 let mut path_mappings = Vec::new();
168 for spec in cli_path_map
169 .or(file_path_map)
170 .unwrap_or_default()
171 .into_iter()
172 .filter(|s| !s.trim().is_empty())
173 {
174 path_mappings.push(PathMapping::parse(&spec)?);
175 }
176 path_mappings.sort_by_key(|m| std::cmp::Reverse(m.internal_prefix.as_os_str().len()));
178
179 let single_workbook = cli_single_workbook.or(file_single_workbook);
180
181 let workspace_root = cli_workspace_root
182 .or(file_workspace_root)
183 .or_else(|| {
184 single_workbook.as_ref().and_then(|path| {
185 if path.is_absolute() {
186 path.parent().map(|parent| parent.to_path_buf())
187 } else {
188 None
189 }
190 })
191 })
192 .unwrap_or_else(|| PathBuf::from("."));
193
194 let screenshot_dir = cli_screenshot_dir
195 .or(file_screenshot_dir)
196 .map(|p| {
197 if p.is_absolute() {
198 p
199 } else {
200 workspace_root.join(p)
201 }
202 })
203 .unwrap_or_else(|| workspace_root.join("screenshots"));
204
205 let cache_capacity = cli_cache_capacity
206 .or(file_cache_capacity)
207 .unwrap_or(DEFAULT_CACHE_CAPACITY)
208 .max(1);
209
210 let mut supported_extensions = cli_extensions
211 .or(file_extensions)
212 .unwrap_or_else(|| {
213 DEFAULT_EXTENSIONS
214 .iter()
215 .map(|ext| (*ext).to_string())
216 .collect()
217 })
218 .into_iter()
219 .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
220 .filter(|ext| !ext.is_empty())
221 .collect::<Vec<_>>();
222
223 supported_extensions.sort();
224 supported_extensions.dedup();
225
226 anyhow::ensure!(
227 !supported_extensions.is_empty(),
228 "at least one file extension must be provided"
229 );
230
231 let single_workbook = single_workbook.map(|path| {
232 if path.is_absolute() {
233 path
234 } else {
235 workspace_root.join(path)
236 }
237 });
238
239 if let Some(workbook_path) = single_workbook.as_ref() {
240 anyhow::ensure!(
241 workbook_path.exists(),
242 "configured workbook {:?} does not exist",
243 workbook_path
244 );
245 anyhow::ensure!(
246 workbook_path.is_file(),
247 "configured workbook {:?} is not a file",
248 workbook_path
249 );
250 let allowed = workbook_path
251 .extension()
252 .and_then(|ext| ext.to_str())
253 .map(|ext| ext.to_ascii_lowercase())
254 .map(|ext| supported_extensions.contains(&ext))
255 .unwrap_or(false);
256 anyhow::ensure!(
257 allowed,
258 "configured workbook {:?} does not match allowed extensions {:?}",
259 workbook_path,
260 supported_extensions
261 );
262 }
263
264 let enabled_tools = cli_enabled_tools
265 .or(file_enabled_tools)
266 .map(|tools| {
267 tools
268 .into_iter()
269 .map(|tool| tool.to_ascii_lowercase())
270 .filter(|tool| !tool.is_empty())
271 .collect::<HashSet<_>>()
272 })
273 .filter(|set| !set.is_empty());
274
275 let transport = cli_transport
276 .or(file_transport)
277 .unwrap_or(TransportKind::Http);
278
279 let http_bind_address = cli_http_bind.or(file_http_bind).unwrap_or_else(|| {
280 DEFAULT_HTTP_BIND
281 .parse()
282 .expect("default bind address valid")
283 });
284
285 let recalc_enabled = cli_recalc_enabled || file_recalc_enabled.unwrap_or(false);
286 let recalc_backend = cli_recalc_backend
287 .or(file_recalc_backend)
288 .unwrap_or_default();
289 let vba_enabled = cli_vba_enabled || file_vba_enabled.unwrap_or(false);
290
291 let max_concurrent_recalcs = cli_max_concurrent_recalcs
292 .or(file_max_concurrent_recalcs)
293 .unwrap_or(DEFAULT_MAX_RECALCS)
294 .max(1);
295
296 let tool_timeout_ms = cli_tool_timeout_ms
297 .or(file_tool_timeout_ms)
298 .unwrap_or(DEFAULT_TOOL_TIMEOUT_MS);
299 let tool_timeout_ms = if tool_timeout_ms == 0 {
300 None
301 } else {
302 Some(tool_timeout_ms)
303 };
304
305 let max_response_bytes = cli_max_response_bytes
306 .or(file_max_response_bytes)
307 .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES);
308 let max_response_bytes = if max_response_bytes == 0 {
309 None
310 } else {
311 Some(max_response_bytes)
312 };
313
314 let output_profile = cli_output_profile
315 .or(file_output_profile)
316 .unwrap_or_default();
317
318 let max_payload_bytes = cli_max_payload_bytes
319 .or(file_max_payload_bytes)
320 .unwrap_or(DEFAULT_MAX_PAYLOAD_BYTES);
321 let max_payload_bytes = if max_payload_bytes == 0 {
322 None
323 } else {
324 Some(max_payload_bytes)
325 };
326
327 let max_cells = cli_max_cells
328 .or(file_max_cells)
329 .unwrap_or(DEFAULT_MAX_CELLS);
330 let max_cells = if max_cells == 0 {
331 None
332 } else {
333 Some(max_cells)
334 };
335
336 let max_items = cli_max_items
337 .or(file_max_items)
338 .unwrap_or(DEFAULT_MAX_ITEMS);
339 let max_items = if max_items == 0 {
340 None
341 } else {
342 Some(max_items)
343 };
344
345 let allow_overwrite = cli_allow_overwrite || file_allow_overwrite.unwrap_or(false);
346
347 Ok(Self {
348 workspace_root,
349 screenshot_dir,
350 path_mappings,
351 cache_capacity,
352 supported_extensions,
353 single_workbook,
354 enabled_tools,
355 transport,
356 http_bind_address,
357 recalc_enabled,
358 recalc_backend,
359 vba_enabled,
360 max_concurrent_recalcs,
361 tool_timeout_ms,
362 max_response_bytes,
363 output_profile,
364 max_payload_bytes,
365 max_cells,
366 max_items,
367 allow_overwrite,
368 })
369 }
370
371 pub fn ensure_workspace_root(&self) -> Result<()> {
372 anyhow::ensure!(
373 self.workspace_root.exists(),
374 "workspace root {:?} does not exist",
375 self.workspace_root
376 );
377 anyhow::ensure!(
378 self.workspace_root.is_dir(),
379 "workspace root {:?} is not a directory",
380 self.workspace_root
381 );
382 if let Some(workbook) = self.single_workbook.as_ref() {
383 anyhow::ensure!(
384 workbook.exists(),
385 "configured workbook {:?} does not exist",
386 workbook
387 );
388 anyhow::ensure!(
389 workbook.is_file(),
390 "configured workbook {:?} is not a file",
391 workbook
392 );
393 }
394 Ok(())
395 }
396
397 pub fn map_path_for_client<P: AsRef<Path>>(&self, internal_path: P) -> Option<PathBuf> {
398 let internal_path = internal_path.as_ref();
399 for m in &self.path_mappings {
400 if internal_path.starts_with(&m.internal_prefix) {
401 let suffix = internal_path.strip_prefix(&m.internal_prefix).ok()?;
402 return Some(m.client_prefix.join(suffix));
403 }
404 }
405 None
406 }
407
408 pub fn map_path_from_client<P: AsRef<Path>>(&self, client_path: P) -> Option<PathBuf> {
409 let client_path = client_path.as_ref();
410 for m in &self.path_mappings {
411 if client_path.starts_with(&m.client_prefix) {
412 let suffix = client_path.strip_prefix(&m.client_prefix).ok()?;
413 return Some(m.internal_prefix.join(suffix));
414 }
415 }
416 None
417 }
418
419 pub fn resolve_user_path<P: AsRef<Path>>(&self, p: P) -> PathBuf {
424 let p = p.as_ref();
425 if p.is_absolute() {
426 self.map_path_from_client(p)
427 .unwrap_or_else(|| p.to_path_buf())
428 } else {
429 self.workspace_root.join(p)
430 }
431 }
432
433 pub fn resolve_path<P: AsRef<Path>>(&self, relative: P) -> PathBuf {
434 let relative = relative.as_ref();
435 if relative.is_absolute() {
436 relative.to_path_buf()
437 } else {
438 self.workspace_root.join(relative)
439 }
440 }
441
442 pub fn single_workbook(&self) -> Option<&Path> {
443 self.single_workbook.as_deref()
444 }
445
446 pub fn is_tool_enabled(&self, tool: &str) -> bool {
447 match &self.enabled_tools {
448 Some(set) => set.contains(&tool.to_ascii_lowercase()),
449 None => true,
450 }
451 }
452
453 pub fn tool_timeout(&self) -> Option<Duration> {
454 self.tool_timeout_ms.and_then(|ms| {
455 if ms > 0 {
456 Some(Duration::from_millis(ms))
457 } else {
458 None
459 }
460 })
461 }
462
463 pub fn max_response_bytes(&self) -> Option<usize> {
464 self.max_response_bytes.and_then(|bytes| {
465 if bytes > 0 {
466 Some(bytes as usize)
467 } else {
468 None
469 }
470 })
471 }
472
473 pub fn output_profile(&self) -> OutputProfile {
474 self.output_profile
475 }
476
477 pub fn max_payload_bytes(&self) -> Option<usize> {
478 self.max_payload_bytes.map(|bytes| bytes as usize)
479 }
480
481 pub fn max_cells(&self) -> Option<usize> {
482 self.max_cells.map(|cells| cells as usize)
483 }
484
485 pub fn max_items(&self) -> Option<usize> {
486 self.max_items.map(|items| items as usize)
487 }
488}
489
490#[derive(Parser, Debug, Default, Clone)]
491#[command(name = "spreadsheet-mcp", about = "Spreadsheet MCP server", version)]
492pub struct CliArgs {
493 #[arg(
494 long,
495 value_name = "FILE",
496 help = "Path to a configuration file (YAML or JSON)",
497 global = true
498 )]
499 pub config: Option<PathBuf>,
500
501 #[arg(
502 long,
503 env = "SPREADSHEET_MCP_WORKSPACE",
504 value_name = "DIR",
505 help = "Workspace root containing spreadsheet files"
506 )]
507 pub workspace_root: Option<PathBuf>,
508
509 #[arg(
510 long,
511 env = "SPREADSHEET_MCP_SCREENSHOT_DIR",
512 value_name = "DIR",
513 help = "Directory to write screenshot PNGs (default: <workspace_root>/screenshots)"
514 )]
515 pub screenshot_dir: Option<PathBuf>,
516
517 #[arg(
518 long,
519 env = "SPREADSHEET_MCP_PATH_MAP",
520 value_name = "INTERNAL=CLIENT",
521 value_delimiter = ',',
522 help = "Optional path mapping(s) to include client-visible paths in responses (repeatable; useful for Docker volume mounts)"
523 )]
524 pub path_map: Option<Vec<String>>,
525
526 #[arg(
527 long,
528 env = "SPREADSHEET_MCP_CACHE_CAPACITY",
529 value_name = "N",
530 help = "Maximum number of workbooks kept in memory",
531 value_parser = clap::value_parser!(usize)
532 )]
533 pub cache_capacity: Option<usize>,
534
535 #[arg(
536 long,
537 env = "SPREADSHEET_MCP_EXTENSIONS",
538 value_name = "EXT",
539 value_delimiter = ',',
540 help = "Comma-separated list of allowed workbook extensions"
541 )]
542 pub extensions: Option<Vec<String>>,
543
544 #[arg(
545 long,
546 env = "SPREADSHEET_MCP_WORKBOOK",
547 value_name = "FILE",
548 help = "Lock the server to a single workbook path"
549 )]
550 pub workbook: Option<PathBuf>,
551
552 #[arg(
553 long,
554 env = "SPREADSHEET_MCP_ENABLED_TOOLS",
555 value_name = "TOOL",
556 value_delimiter = ',',
557 help = "Restrict execution to the provided tool names"
558 )]
559 pub enabled_tools: Option<Vec<String>>,
560
561 #[arg(
562 long,
563 env = "SPREADSHEET_MCP_TRANSPORT",
564 value_enum,
565 value_name = "TRANSPORT",
566 help = "Transport to expose (http or stdio)"
567 )]
568 pub transport: Option<TransportKind>,
569
570 #[arg(
571 long,
572 env = "SPREADSHEET_MCP_HTTP_BIND",
573 value_name = "ADDR",
574 help = "HTTP bind address when using http transport"
575 )]
576 pub http_bind: Option<SocketAddr>,
577
578 #[arg(
579 long,
580 env = "SPREADSHEET_MCP_RECALC_ENABLED",
581 help = "Enable write/recalc tools (requires LibreOffice)"
582 )]
583 pub recalc_enabled: bool,
584
585 #[arg(
586 long,
587 env = "SPREADSHEET_MCP_RECALC_BACKEND",
588 value_enum,
589 value_name = "KIND",
590 default_value = "auto",
591 help = "Recalc backend preference: auto, formualizer, or libreoffice"
592 )]
593 pub recalc_backend: Option<RecalcBackendKind>,
594
595 #[arg(
596 long,
597 env = "SPREADSHEET_MCP_VBA_ENABLED",
598 help = "Enable VBA introspection tools (read-only)"
599 )]
600 pub vba_enabled: bool,
601
602 #[arg(
603 long,
604 env = "SPREADSHEET_MCP_MAX_CONCURRENT_RECALCS",
605 help = "Max concurrent LibreOffice instances"
606 )]
607 pub max_concurrent_recalcs: Option<usize>,
608
609 #[arg(
610 long,
611 env = "SPREADSHEET_MCP_TOOL_TIMEOUT_MS",
612 value_name = "MS",
613 help = "Tool request timeout in milliseconds (default: 30000; 0 disables)",
614 value_parser = clap::value_parser!(u64)
615 )]
616 pub tool_timeout_ms: Option<u64>,
617
618 #[arg(
619 long,
620 env = "SPREADSHEET_MCP_MAX_RESPONSE_BYTES",
621 value_name = "BYTES",
622 help = "Max response size in bytes (default: 1000000; 0 disables)",
623 value_parser = clap::value_parser!(u64)
624 )]
625 pub max_response_bytes: Option<u64>,
626
627 #[arg(
628 long,
629 env = "SPREADSHEET_MCP_OUTPUT_PROFILE",
630 value_enum,
631 value_name = "PROFILE",
632 help = "Output profile for tool responses (token_dense or verbose)"
633 )]
634 pub output_profile: Option<OutputProfile>,
635
636 #[arg(
637 long,
638 env = "SPREADSHEET_MCP_MAX_PAYLOAD_BYTES",
639 value_name = "BYTES",
640 help = "Max tool payload size in bytes before truncation (default: 65536; 0 disables)",
641 value_parser = clap::value_parser!(u64)
642 )]
643 pub max_payload_bytes: Option<u64>,
644
645 #[arg(
646 long,
647 env = "SPREADSHEET_MCP_MAX_CELLS",
648 value_name = "N",
649 help = "Max cells per tool payload before truncation (default: 10000; 0 disables)",
650 value_parser = clap::value_parser!(u64)
651 )]
652 pub max_cells: Option<u64>,
653
654 #[arg(
655 long,
656 env = "SPREADSHEET_MCP_MAX_ITEMS",
657 value_name = "N",
658 help = "Max items per tool payload before truncation (default: 500; 0 disables)",
659 value_parser = clap::value_parser!(u64)
660 )]
661 pub max_items: Option<u64>,
662
663 #[arg(
664 long,
665 env = "SPREADSHEET_MCP_ALLOW_OVERWRITE",
666 help = "Allow save_fork to overwrite original workbook files"
667 )]
668 pub allow_overwrite: bool,
669}
670
671#[derive(Debug, Default, Deserialize)]
672struct PartialConfig {
673 workspace_root: Option<PathBuf>,
674 screenshot_dir: Option<PathBuf>,
675 path_map: Option<Vec<String>>,
676 cache_capacity: Option<usize>,
677 extensions: Option<Vec<String>>,
678 single_workbook: Option<PathBuf>,
679 enabled_tools: Option<Vec<String>>,
680 transport: Option<TransportKind>,
681 http_bind: Option<SocketAddr>,
682 recalc_enabled: Option<bool>,
683 recalc_backend: Option<RecalcBackendKind>,
684 vba_enabled: Option<bool>,
685 max_concurrent_recalcs: Option<usize>,
686 tool_timeout_ms: Option<u64>,
687 max_response_bytes: Option<u64>,
688 output_profile: Option<OutputProfile>,
689 max_payload_bytes: Option<u64>,
690 max_cells: Option<u64>,
691 max_items: Option<u64>,
692 allow_overwrite: Option<bool>,
693}
694
695fn load_config_file(path: &Path) -> Result<PartialConfig> {
696 if !path.exists() {
697 anyhow::bail!("config file {:?} does not exist", path);
698 }
699 let contents = fs::read_to_string(path)
700 .with_context(|| format!("failed to read config file {:?}", path))?;
701 let ext = path
702 .extension()
703 .and_then(|os| os.to_str())
704 .unwrap_or("")
705 .to_ascii_lowercase();
706
707 let parsed = match ext.as_str() {
708 "yaml" | "yml" => serde_yaml::from_str(&contents)
709 .with_context(|| format!("failed to parse YAML config {:?}", path))?,
710 "json" => serde_json::from_str(&contents)
711 .with_context(|| format!("failed to parse JSON config {:?}", path))?,
712 other => anyhow::bail!("unsupported config extension: {other}"),
713 };
714 Ok(parsed)
715}