Skip to main content

lean_host_mcp/
config_file.rs

1//! Optional TOML config file holding every tunable knob.
2//!
3//! One file can set the runtime/worker, broker/pool, and server/transport
4//! knobs that are otherwise `LEAN_HOST_MCP_*` env vars, plus the existing
5//! `primary_project`. Discovery prefers a project-local `lean-host-mcp.toml`
6//! (found by walking up from the invocation cwd, like the lakefile) and falls
7//! back to the home file `<config-dir>/lean-host-mcp/config.toml`. When both
8//! exist they merge **per key** (local overlays home), so a project file need
9//! only restate the knobs it changes.
10//!
11//! The file is one layer in the precedence stack `CLI > env > file > default`:
12//! every field here is `Option`, and a present env var still wins over a file
13//! value (see `ProjectRuntimeConfig::from_env_with_file` /
14//! `BrokerConfig::pool_from_env_with_file`). Missing files and parse failures
15//! are non-fatal — a malformed file is logged and ignored, leaving env + the
16//! built-in defaults in charge.
17
18use std::path::{Path, PathBuf};
19
20use serde::Deserialize;
21
22/// Project-local file name, discovered by walking up from the cwd. Also the
23/// default destination `config init` writes to.
24pub(crate) const LOCAL_FILE_NAME: &str = "lean-host-mcp.toml";
25
26/// Merged view of the config file(s). All fields optional: an absent field
27/// defers to the env var, then the built-in default.
28#[derive(Debug, Default, Deserialize)]
29pub struct ConfigFile {
30    /// Default Lake project for calls without a `project=` argument. Kept for
31    /// backward compatibility with the original `primary_project`-only file.
32    pub primary_project: Option<PathBuf>,
33    #[serde(default)]
34    pub runtime: RuntimeFileConfig,
35    #[serde(default)]
36    pub broker: BrokerFileConfig,
37    #[serde(default)]
38    pub server: ServerFileConfig,
39    #[serde(default)]
40    pub telemetry: TelemetryFileConfig,
41    #[serde(default)]
42    pub output: OutputFileConfig,
43}
44
45/// `[runtime]` — worker policy knobs (mirrors `ProjectRuntimeConfig`).
46#[derive(Debug, Default, Deserialize)]
47pub struct RuntimeFileConfig {
48    pub worker_rss_post_job_restart_kib: Option<u64>,
49    pub worker_rss_hard_kill_kib: Option<u64>,
50    pub worker_rss_sample_millis: Option<u64>,
51    pub import_switch_rss_soft_kib: Option<u64>,
52    pub module_cache_rss_guard_kib: Option<u64>,
53    pub module_cache_max_bytes: Option<u64>,
54    pub request_timeout_millis: Option<u64>,
55    pub project_mailbox_capacity: Option<usize>,
56    pub worker_restart_limit: Option<usize>,
57    pub worker_restart_window_secs: Option<u64>,
58}
59
60/// `[broker]` — project-pool and semantic-admission knobs.
61#[derive(Debug, Default, Deserialize)]
62pub struct BrokerFileConfig {
63    pub max_projects: Option<usize>,
64    pub idle_timeout_secs: Option<u64>,
65    pub semantic_permits: Option<usize>,
66    pub semantic_waiters: Option<usize>,
67    pub semantic_admission_timeout_millis: Option<u64>,
68    pub semantic_lock_dir: Option<PathBuf>,
69}
70
71/// `[server]` — transport knobs.
72///
73/// `lake_root` is intentionally absent: the project default lives in the
74/// top-level `primary_project` key. `bind` is a raw string (e.g.
75/// `"127.0.0.1:8765"`); the binary parses and validates it as a `SocketAddr`,
76/// so this library schema stays free of transport types.
77#[derive(Debug, Default, Deserialize)]
78pub struct ServerFileConfig {
79    pub bind: Option<String>,
80    pub http_path: Option<String>,
81    /// Which field of the tool result carries the envelope: `text` (default),
82    /// `structured`, or `both`. See `crate::tools::ResponseCarrier`.
83    pub response_carrier: Option<String>,
84}
85
86/// `[telemetry]` — how much operational telemetry the model-facing envelope
87/// carries (`quiet` default, or `full`). See `crate::tools::TelemetryVerbosity`.
88#[derive(Debug, Default, Deserialize)]
89pub struct TelemetryFileConfig {
90    pub verbosity: Option<String>,
91}
92
93/// `[output]` — server-wide output budget overrides.
94///
95/// These replace the former per-call `max_field_bytes` / `max_total_bytes` /
96/// `heartbeat_limit` request arguments. Each is optional: unset keeps the
97/// per-tool built-in default.
98#[derive(Debug, Default, Deserialize)]
99pub struct OutputFileConfig {
100    pub max_field_bytes: Option<u32>,
101    pub max_total_bytes: Option<u32>,
102    pub heartbeat_limit: Option<u64>,
103}
104
105impl ConfigFile {
106    /// Load and merge the home and project-local files. Home is the base; the
107    /// nearest `lean-host-mcp.toml` at or above `cwd` overlays it per key. A
108    /// missing file contributes nothing; a malformed file is logged and
109    /// skipped. Never fails: the worst case is an empty config (env + defaults).
110    #[must_use]
111    pub fn load(cwd: &Path) -> Self {
112        let mut merged = toml::Value::Table(toml::Table::new());
113        if let Some(home) = home_config_path()
114            && let Some(value) = read_toml(&home)
115        {
116            merge_toml(&mut merged, value);
117        }
118        if let Some(local) = walk_up_for(cwd, LOCAL_FILE_NAME)
119            && let Some(value) = read_toml(&local)
120        {
121            merge_toml(&mut merged, value);
122        }
123        match merged.try_into() {
124            Ok(config) => config,
125            Err(err) => {
126                tracing::warn!(error = %err, "merged config did not match the schema; ignoring it");
127                Self::default()
128            }
129        }
130    }
131}
132
133/// `<config-dir>/lean-host-mcp/config.toml`. `LEAN_HOST_MCP_CONFIG_DIR`
134/// overrides the base dir (used by the test suite to avoid the developer's
135/// real config). Shared with `config init --home`, so discovery and
136/// generation target the same path.
137pub(crate) fn home_config_path() -> Option<PathBuf> {
138    let base = std::env::var_os("LEAN_HOST_MCP_CONFIG_DIR")
139        .map(PathBuf::from)
140        .or_else(dirs::config_dir)?;
141    Some(base.join("lean-host-mcp").join("config.toml"))
142}
143
144/// Walk upward from `start` looking for `filename`; return the first match.
145fn walk_up_for(start: &Path, filename: &str) -> Option<PathBuf> {
146    let mut cur: Option<&Path> = Some(start);
147    while let Some(dir) = cur {
148        let candidate = dir.join(filename);
149        if candidate.is_file() {
150            return Some(candidate);
151        }
152        cur = dir.parent();
153    }
154    None
155}
156
157/// Read and parse one TOML file. Missing file → `None` (silent); parse error
158/// → `None` with a warning, so a typo in one file can't take down startup.
159fn read_toml(path: &Path) -> Option<toml::Value> {
160    let contents = std::fs::read_to_string(path).ok()?;
161    match toml::from_str::<toml::Value>(&contents) {
162        Ok(value) => Some(value),
163        Err(err) => {
164            tracing::warn!(path = %path.display(), error = %err, "config file parse failed; ignoring");
165            None
166        }
167    }
168}
169
170/// Deep-merge `overlay` onto `base` in place: tables merge key-by-key
171/// (recursing), any non-table value replaces wholesale. So `[runtime]` in a
172/// local file overrides only the keys it sets, leaving sibling home keys.
173fn merge_toml(base: &mut toml::Value, overlay: toml::Value) {
174    match (base, overlay) {
175        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
176            for (key, value) in overlay_table {
177                match base_table.get_mut(&key) {
178                    Some(existing) => merge_toml(existing, value),
179                    None => {
180                        base_table.insert(key, value);
181                    }
182                }
183            }
184        }
185        (slot, value) => *slot = value,
186    }
187}
188
189#[cfg(test)]
190#[allow(
191    clippy::unwrap_used,
192    clippy::panic,
193    reason = "tests assert the branch under test directly"
194)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn merge_overlays_local_over_home_per_key() {
200        let mut base = toml::from_str::<toml::Value>(
201            "primary_project = \"/home/proj\"\n[runtime]\nworker_rss_post_job_restart_kib = 5\n[broker]\nmax_projects = 8\n",
202        )
203        .unwrap();
204        let overlay = toml::from_str::<toml::Value>(
205            "[runtime]\nworker_rss_post_job_restart_kib = 8\nworker_rss_hard_kill_kib = 16\n",
206        )
207        .unwrap();
208        merge_toml(&mut base, overlay);
209        let config: ConfigFile = base.try_into().unwrap();
210
211        // Local overrode the one runtime key it set...
212        assert_eq!(config.runtime.worker_rss_post_job_restart_kib, Some(8));
213        // ...added its own...
214        assert_eq!(config.runtime.worker_rss_hard_kill_kib, Some(16));
215        // ...and left untouched home keys (runtime sibling + other sections) intact.
216        assert_eq!(config.broker.max_projects, Some(8));
217        assert_eq!(config.primary_project.as_deref(), Some(Path::new("/home/proj")));
218    }
219
220    #[test]
221    fn full_example_parses() {
222        let config: ConfigFile = toml::from_str::<toml::Value>(
223            "[runtime]\nworker_rss_post_job_restart_kib = 8388608\nworker_restart_window_secs = 60\n\
224             [broker]\nmax_projects = 4\nsemantic_permits = 1\n\
225             [server]\nbind = \"127.0.0.1:8765\"\nhttp_path = \"/mcp\"\n",
226        )
227        .unwrap()
228        .try_into()
229        .unwrap();
230
231        assert_eq!(config.runtime.worker_rss_post_job_restart_kib, Some(8_388_608));
232        assert_eq!(config.broker.max_projects, Some(4));
233        assert_eq!(config.server.bind.as_deref(), Some("127.0.0.1:8765"));
234        assert_eq!(config.server.http_path.as_deref(), Some("/mcp"));
235    }
236
237    #[test]
238    fn empty_config_is_all_none() {
239        let config = ConfigFile::default();
240        assert!(config.primary_project.is_none());
241        assert!(config.runtime.worker_rss_post_job_restart_kib.is_none());
242        assert!(config.broker.max_projects.is_none());
243        assert!(config.server.bind.is_none());
244    }
245
246    #[test]
247    fn walk_up_finds_nearest_then_ancestor() {
248        let tmp = std::env::temp_dir().join(format!("lhm-cfg-walk-{}", std::process::id()));
249        let nested = tmp.join("a").join("b");
250        std::fs::create_dir_all(&nested).unwrap();
251        std::fs::write(tmp.join(LOCAL_FILE_NAME), "max_projects_unused = 1\n").unwrap();
252        // No file in nested or a/: walk-up should find the one at tmp.
253        let found = walk_up_for(&nested, LOCAL_FILE_NAME).unwrap();
254        assert_eq!(found, tmp.join(LOCAL_FILE_NAME));
255        std::fs::remove_dir_all(&tmp).ok();
256    }
257}