Skip to main content

spreadsheet_mcp/
config.rs

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    /// Directory to write screenshot PNGs into (screenshot_sheet).
62    pub screenshot_dir: PathBuf,
63    /// Optional mapping from server-internal paths to client/host-visible paths.
64    /// This is primarily useful when the server runs in Docker and volumes are mounted.
65    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        // Prefer longer, more specific prefixes first.
177        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    /// Resolve a user-supplied path for tools (e.g. save_fork target_path).
420    /// - If the path is absolute and matches a configured client path mapping, map it to internal.
421    /// - Otherwise, treat absolute paths as internal.
422    /// - Relative paths are resolved under workspace_root.
423    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}