Skip to main content

mcp_methods/server/
env.rs

1//! `.env` discovery + loader.
2//!
3//! Walks upward from a start directory looking for a `.env` file and
4//! loads the first one found into the process environment. Mirrors
5//! the Python `mcp_methods._utils.load_env` semantics exactly so the
6//! same `.env` file behaves identically whether the server boots
7//! via Rust or Python:
8//! - Skip blank lines and lines starting with `#`.
9//! - `KEY=VALUE` only (lines without `=` are skipped).
10//! - Values may be quoted with single or double quotes; the outer
11//!   quotes are stripped.
12//! - Existing env vars are NOT overwritten.
13//!
14//! Operators who want a non-implicit pick can declare `env_file: path`
15//! at the top level of the manifest YAML; that wins over the walk-up.
16
17use std::fs;
18use std::path::{Path, PathBuf};
19
20/// Walk upward from `start` looking for `.env`. Returns the path
21/// loaded, or `None` if nothing was found before reaching the root.
22/// Silent on parse errors per-line — a single bad line should not
23/// stop the rest of the file from loading.
24pub fn load_env_walk(start: &Path) -> Option<PathBuf> {
25    let mut cursor: PathBuf = if start.is_absolute() {
26        start.to_path_buf()
27    } else {
28        start.canonicalize().unwrap_or_else(|_| start.to_path_buf())
29    };
30    loop {
31        let candidate = cursor.join(".env");
32        if candidate.is_file() {
33            apply_env_file(&candidate);
34            return Some(candidate);
35        }
36        if !cursor.pop() {
37            return None;
38        }
39    }
40}
41
42/// Load a specific `.env` path. Errors if the file does not exist —
43/// the explicit `env_file:` manifest key promises that path.
44pub fn load_env_explicit(path: &Path) -> Result<(), String> {
45    if !path.is_file() {
46        return Err(format!("env_file does not exist: {}", path.display()));
47    }
48    apply_env_file(path);
49    Ok(())
50}
51
52fn apply_env_file(path: &Path) {
53    let Ok(text) = fs::read_to_string(path) else {
54        return;
55    };
56    for line in text.lines() {
57        let trimmed = line.trim();
58        if trimmed.is_empty() || trimmed.starts_with('#') {
59            continue;
60        }
61        let Some(eq) = trimmed.find('=') else {
62            continue;
63        };
64        let key = trimmed[..eq].trim();
65        let val = trimmed[eq + 1..].trim();
66        let val = strip_outer_quotes(val);
67        if key.is_empty() {
68            continue;
69        }
70        if std::env::var_os(key).is_some() {
71            continue;
72        }
73        // SAFETY: PyO3-embedded Python is not yet initialised when this
74        // runs in `main.rs`; the only readers of the env at this point
75        // are downstream tool implementations (e.g. github::gh_get) that
76        // call `env::var` lazily. Setting env on the main thread before
77        // tokio runtime work is the standard pre-boot pattern.
78        unsafe { std::env::set_var(key, val) };
79    }
80}
81
82fn strip_outer_quotes(s: &str) -> &str {
83    let bytes = s.as_bytes();
84    if bytes.len() >= 2 {
85        let first = bytes[0];
86        let last = bytes[bytes.len() - 1];
87        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
88            return &s[1..s.len() - 1];
89        }
90    }
91    s
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::io::Write;
98
99    /// Tests mutate process env; serialise to avoid cross-test races.
100    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
101        use std::sync::{Mutex, OnceLock};
102        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
103        LOCK.get_or_init(|| Mutex::new(()))
104            .lock()
105            .unwrap_or_else(|p| p.into_inner())
106    }
107
108    fn write_env(dir: &Path, body: &str) -> PathBuf {
109        let p = dir.join(".env");
110        let mut f = fs::File::create(&p).unwrap();
111        f.write_all(body.as_bytes()).unwrap();
112        p
113    }
114
115    #[test]
116    fn finds_env_in_start_dir() {
117        let _g = env_lock();
118        let dir = tempfile::tempdir().unwrap();
119        let key = "MCP_TEST_DIRECT_HIT";
120        unsafe { std::env::remove_var(key) };
121        write_env(dir.path(), &format!("{key}=ok\n"));
122        let found = load_env_walk(dir.path()).expect("found env");
123        assert!(found.ends_with(".env"));
124        assert_eq!(std::env::var(key).ok().as_deref(), Some("ok"));
125        unsafe { std::env::remove_var(key) };
126    }
127
128    #[test]
129    fn walks_up_to_parent_for_env() {
130        let _g = env_lock();
131        let dir = tempfile::tempdir().unwrap();
132        let key = "MCP_TEST_WALK_UP";
133        unsafe { std::env::remove_var(key) };
134        write_env(dir.path(), &format!("{key}=parent\n"));
135        let sub = dir.path().join("a").join("b").join("c");
136        fs::create_dir_all(&sub).unwrap();
137        let found = load_env_walk(&sub).expect("found env via walk-up");
138        let expected = dir.path().canonicalize().unwrap().join(".env");
139        assert_eq!(found.canonicalize().unwrap(), expected);
140        assert_eq!(std::env::var(key).ok().as_deref(), Some("parent"));
141        unsafe { std::env::remove_var(key) };
142    }
143
144    #[test]
145    fn does_not_overwrite_existing_env() {
146        let _g = env_lock();
147        let dir = tempfile::tempdir().unwrap();
148        let key = "MCP_TEST_NO_OVERWRITE";
149        unsafe { std::env::set_var(key, "preset") };
150        write_env(dir.path(), &format!("{key}=fromfile\n"));
151        load_env_walk(dir.path());
152        assert_eq!(std::env::var(key).ok().as_deref(), Some("preset"));
153        unsafe { std::env::remove_var(key) };
154    }
155
156    #[test]
157    fn strips_quotes_skips_comments() {
158        let _g = env_lock();
159        let dir = tempfile::tempdir().unwrap();
160        let k1 = "MCP_TEST_DQUOTED";
161        let k2 = "MCP_TEST_SQUOTED";
162        let k3 = "MCP_TEST_COMMENT";
163        for k in [k1, k2, k3] {
164            unsafe { std::env::remove_var(k) };
165        }
166        write_env(
167            dir.path(),
168            &format!("# comment\n\n{k1}=\"hello\"\n{k2}='world'\n# {k3}=skipped\n"),
169        );
170        load_env_walk(dir.path()).unwrap();
171        assert_eq!(std::env::var(k1).ok().as_deref(), Some("hello"));
172        assert_eq!(std::env::var(k2).ok().as_deref(), Some("world"));
173        assert!(std::env::var(k3).is_err());
174        for k in [k1, k2, k3] {
175            unsafe { std::env::remove_var(k) };
176        }
177    }
178
179    #[test]
180    fn explicit_missing_path_errors() {
181        let dir = tempfile::tempdir().unwrap();
182        let missing = dir.path().join("nope.env");
183        assert!(load_env_explicit(&missing).is_err());
184    }
185
186    #[test]
187    fn returns_none_when_no_env_anywhere() {
188        let _g = env_lock();
189        let dir = tempfile::tempdir().unwrap();
190        // walk-up will still hit /, but no .env at the temp leaf and
191        // (usually) nothing on the temp path. Either way the call is
192        // safe and idempotent — we just assert it doesn't panic.
193        let _ = load_env_walk(dir.path());
194    }
195}