Skip to main content

oxicode_agent/mcp/
config.rs

1//! MCP configuration loading.
2//!
3//! Discovers and loads MCP server configuration from standard locations:
4//! - `~/.config/mcp/mcp.json` (shared global)
5//! - `<config_dir>/oxicode/mcp.json` (oxicode-specific global)
6//! - `<cwd>/.mcp.json` (shared project)
7//! - `<cwd>/.oxicode/mcp.json` (oxicode-specific project)
8
9use super::types::McpConfig;
10use anyhow::{Context, Result};
11use std::path::{Path, PathBuf};
12
13/// Resolve all config file paths to try (in priority order).
14pub fn config_paths(cwd: &Path) -> Vec<PathBuf> {
15    let mut paths = Vec::new();
16
17    // 1. Shared global config
18    if let Some(config_dir) = dirs::config_dir() {
19        paths.push(config_dir.join("mcp").join("mcp.json"));
20    }
21
22    // 2. oxicode-specific global config
23    if let Some(config_dir) = dirs::config_dir() {
24        paths.push(config_dir.join("oxicode").join("mcp.json"));
25    }
26
27    // 3. Shared project config
28    paths.push(cwd.join(".mcp.json"));
29
30    // 4. oxicode-specific project config
31    paths.push(cwd.join(".oxicode").join("mcp.json"));
32
33    paths
34}
35
36/// Global config paths only (`~/.config/mcp/mcp.json` and
37/// `~/.config/oxicode/mcp.json`). These are user-authored and trusted for the
38/// one-time consent migration in [`crate::mcp::McpManager::spawn_with_paths`].
39/// Project-local paths (`.mcp.json`, `.oxicode/mcp.json`) are deliberately
40/// excluded — a cloned repo may ship a malicious project-local config, and
41/// auto-trusting it would reopen the clone-to-RCE surface F-2 closes.
42pub fn global_config_paths() -> Vec<PathBuf> {
43    let mut paths = Vec::new();
44    if let Some(config_dir) = dirs::config_dir() {
45        paths.push(config_dir.join("mcp").join("mcp.json"));
46        paths.push(config_dir.join("oxicode").join("mcp.json"));
47    }
48    paths
49}
50
51/// Load MCP configuration, merging all discovered config files.
52///
53/// Later files override earlier ones for server entries.
54/// Returns an empty config if no files exist.
55pub fn load_mcp_config() -> McpConfig {
56    let cwd = match std::env::current_dir() {
57        Ok(c) => c,
58        Err(_) => return McpConfig::default(),
59    };
60    load_mcp_config_from(&cwd)
61}
62
63/// Load MCP configuration from a specific working directory.
64///
65/// oxicode's own paths are merged first (later files override earlier).
66/// Then, if `settings.discoverExternalConfigs` is enabled, third-party
67/// tool configs (`external_paths`) are merged with **lower priority**:
68/// they only add server entries oxicode did not already define and never
69/// override oxicode's `settings`. v2.4 / G8.
70pub fn load_mcp_config_from(cwd: &Path) -> McpConfig {
71    let mut merged = McpConfig::default();
72
73    for path in config_paths(cwd) {
74        if let Some(config) = read_config_file(&path) {
75            for (name, entry) in config.mcp_servers {
76                merged.mcp_servers.insert(name, entry);
77            }
78            if config.settings.is_some() {
79                merged.settings = config.settings;
80            }
81        }
82    }
83
84    // Opt-in third-party discovery. oxicode's own entries + settings win.
85    let discover = merged
86        .settings
87        .as_ref()
88        .and_then(|s| s.discover_external_configs)
89        .unwrap_or(false);
90    if discover {
91        for path in external_paths(cwd) {
92            if let Some(config) = read_config_file(&path) {
93                for (name, entry) in config.mcp_servers {
94                    merged.mcp_servers.entry(name).or_insert(entry);
95                }
96            }
97        }
98    }
99
100    merged
101}
102
103/// Third-party tool config files to optionally discover (v2.4 / G8).
104/// Only files using the same `{"mcpServers": {...}}` schema as oxicode are
105/// supported here; VS Code's `{"servers": ...}` and opencode's
106/// `{"mcp": ...}` schemas need normalization and are out of v2.4 scope.
107fn external_paths(cwd: &Path) -> Vec<PathBuf> {
108    vec![
109        cwd.join(".claude").join("mcp.json"),
110        cwd.join(".cursor").join("mcp.json"),
111    ]
112}
113
114/// Read and parse a single config file. Returns `None` if the file
115/// doesn't exist or is invalid.
116pub fn read_config_file(path: &Path) -> Option<McpConfig> {
117    if !path.exists() {
118        return None;
119    }
120
121    let content = std::fs::read_to_string(path).ok()?;
122
123    match serde_json::from_str::<McpConfig>(&content) {
124        Ok(mut config) => {
125            resolve_config(&mut config);
126            Some(config)
127        }
128        Err(e) => {
129            tracing::warn!("Failed to parse MCP config {}: {}", path.display(), e);
130            None
131        }
132    }
133}
134
135/// The default **write target** for global (user-wide) MCP config.
136///
137/// This is the oxicode-owned global file (`~/.config/oxicode/mcp.json` on
138/// Unix, the platform equivalent elsewhere). Returns `None` only when
139/// the platform has no resolvable config directory.
140pub fn default_write_path_global() -> Option<PathBuf> {
141    dirs::config_dir().map(|d| d.join("oxicode").join("mcp.json"))
142}
143
144/// The default **write target** for project-local MCP config:
145/// `<cwd>/.oxicode/mcp.json`.
146pub fn default_write_path_project(cwd: &Path) -> PathBuf {
147    cwd.join(".oxicode").join("mcp.json")
148}
149
150/// Load a single config file, returning an empty config if it does
151/// not exist (rather than `None`) — convenient for the TUI editor
152/// which wants to edit "the file" whether or not it exists yet.
153pub fn load_or_default(path: &Path) -> McpConfig {
154    read_config_file(path).unwrap_or_default()
155}
156
157/// Resolve `ServerEntry` string fields against the process environment
158/// and (for `!cmd` values) the host shell. Called by [`read_config_file`]
159/// after parsing so that values stored in `mcp.json` are ready to use at
160/// connect time without further substitution.
161///
162/// Rules (matching the OMP behaviour):
163/// - Values starting with `!` run through a shell (`sh -c` on Unix,
164///   `cmd /C` on Windows) with a 10s timeout. The trimmed stdout replaces
165///   the value; failure or empty stdout yields `None`.
166/// - Other values have `${VAR}` and `${VAR:-default}` placeholders
167///   expanded against the process environment; unresolved placeholders
168///   remain literal (so a misconfigured value surfaces rather than
169///   silently disappearing).
170pub fn resolve_config(cfg: &mut McpConfig) {
171    for (name, entry) in cfg.mcp_servers.iter_mut() {
172        if let Some(s) = entry.command.as_ref() {
173            entry.command = match resolve_value(s) {
174                Some(v) => Some(v),
175                None => {
176                    tracing::warn!("MCP server '{}': failed to resolve command", name);
177                    None
178                }
179            };
180        }
181        if let Some(args) = entry.args.as_mut() {
182            args.retain_mut(|a| match resolve_value(a) {
183                Some(r) => {
184                    *a = r;
185                    true
186                }
187                None => false,
188            });
189        }
190        if let Some(s) = entry.cwd.as_ref() {
191            entry.cwd = match resolve_value(s) {
192                Some(v) => Some(v),
193                None => {
194                    tracing::warn!("MCP server '{}': failed to resolve cwd", name);
195                    None
196                }
197            };
198        }
199        if let Some(s) = entry.url.as_ref() {
200            entry.url = match resolve_value(s) {
201                Some(v) => Some(v),
202                None => {
203                    tracing::warn!("MCP server '{}': failed to resolve url", name);
204                    None
205                }
206            };
207        }
208        if let Some(env) = entry.env.as_mut() {
209            env.retain(|_, v| match resolve_value(v) {
210                Some(r) => {
211                    *v = r;
212                    true
213                }
214                None => false,
215            });
216        }
217        if let Some(headers) = entry.headers.as_mut() {
218            headers.retain(|_, v| match resolve_value(v) {
219                Some(r) => {
220                    *v = r;
221                    true
222                }
223                None => false,
224            });
225        }
226    }
227}
228
229fn resolve_value(value: &str) -> Option<String> {
230    if let Some(cmd) = value.strip_prefix('!') {
231        run_shell_capture(cmd, std::time::Duration::from_secs(10))
232    } else {
233        Some(expand_env_placeholders(value))
234    }
235}
236
237fn run_shell_capture(cmd: &str, timeout: std::time::Duration) -> Option<String> {
238    let (tx, rx) = std::sync::mpsc::channel();
239    let thread_cmd = cmd.to_string();
240    std::thread::spawn(move || {
241        let result = shell_command_output(&thread_cmd);
242        let _ = tx.send(result);
243    });
244    match rx.recv_timeout(timeout) {
245        Ok(Ok(out)) => {
246            let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
247            if s.is_empty() { None } else { Some(s) }
248        }
249        _ => None,
250    }
251}
252
253#[cfg(unix)]
254fn shell_command_output(cmd: &str) -> std::io::Result<std::process::Output> {
255    std::process::Command::new("sh").arg("-c").arg(cmd).output()
256}
257
258#[cfg(not(unix))]
259fn shell_command_output(cmd: &str) -> std::io::Result<std::process::Output> {
260    std::process::Command::new("cmd")
261        .arg("/C")
262        .arg(cmd)
263        .output()
264}
265
266fn expand_env_placeholders(input: &str) -> String {
267    let mut out = String::with_capacity(input.len());
268    let bytes = input.as_bytes();
269    let mut i = 0;
270    while i < bytes.len() {
271        if bytes[i] == b'$'
272            && i + 1 < bytes.len()
273            && bytes[i + 1] == b'{'
274            && let Some(end_rel) = input[i + 2..].find('}')
275        {
276            let inner = &input[i + 2..i + 2 + end_rel];
277            let (name, default) = match inner.split_once(":-") {
278                Some((n, d)) => (n, Some(d)),
279                None => (inner, None),
280            };
281            let value = std::env::var(name)
282                .ok()
283                .or_else(|| default.map(|d| d.to_string()));
284            if let Some(v) = value {
285                out.push_str(&v);
286            } else {
287                out.push_str(&input[i..i + 2 + end_rel + 1]);
288            }
289            i += 2 + end_rel + 1;
290            continue;
291        }
292        // SAFETY: `i` is advanced only by complete UTF-8 char boundaries, so
293        // `input[i..]` starts at a valid boundary while `i < bytes.len()` —
294        // `chars().next()` cannot return None.
295        #[allow(clippy::expect_used)]
296        let ch = input[i..].chars().next().expect("valid UTF-8 boundary");
297        out.push(ch);
298        i += ch.len_utf8();
299    }
300    out
301}
302
303/// Atomically write a full [`McpConfig`] to `path` as pretty-printed
304/// JSON. Creates parent directories as needed. Uses the temp-file +
305/// rename pattern so a crash mid-write cannot corrupt the config.
306pub fn save_mcp_config(path: &Path, config: &McpConfig) -> Result<()> {
307    if let Some(parent) = path.parent() {
308        std::fs::create_dir_all(parent).with_context(|| {
309            format!("Failed to create MCP config directory {}", parent.display())
310        })?;
311    }
312    let json = serde_json::to_string_pretty(config).context("Failed to serialize MCP config")?;
313    let tmp = path.with_extension("json.tmp");
314    std::fs::write(&tmp, &json)
315        .with_context(|| format!("Failed to write MCP config tmp {}", tmp.display()))?;
316    std::fs::rename(&tmp, path).with_context(|| {
317        format!(
318            "Failed to rename MCP config {} → {}",
319            tmp.display(),
320            path.display()
321        )
322    })?;
323    Ok(())
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_empty_config_when_no_files() {
332        let config = load_mcp_config_from(Path::new("/nonexistent"));
333        assert!(config.mcp_servers.is_empty());
334    }
335}
336
337#[cfg(test)]
338mod compat_tests {
339    use super::*;
340    use crate::mcp::types::McpConfig;
341
342    #[test]
343    fn reads_standard_camel_case_mcp_config() {
344        // The canonical MCP ecosystem format (Cursor / Claude / pi-mcp-adapter).
345        let json = r#"{
346            "mcpServers": {
347                "filesystem": {
348                    "command": "npx",
349                    "args": ["-y", "@modelcontextprotocol/server-filesystem"],
350                    "idleTimeout": 15,
351                    "directTools": true
352                }
353            },
354            "settings": {
355                "toolPrefix": "server",
356                "idleTimeout": 10
357            }
358        }"#;
359        let cfg: McpConfig = serde_json::from_str(json).unwrap();
360        let fs = cfg.mcp_servers.get("filesystem").expect("server present");
361        assert_eq!(fs.command.as_deref(), Some("npx"));
362        assert_eq!(fs.idle_timeout, Some(15));
363        assert!(fs.direct_tools.is_some());
364        assert!(cfg.settings.as_ref().unwrap().tool_prefix.is_some());
365        assert_eq!(cfg.settings.as_ref().unwrap().idle_timeout, Some(10));
366    }
367
368    #[test]
369    fn reads_legacy_snake_case_mcp_config() {
370        let json = r#"{
371            "mcpServers": {
372                "legacy": {
373                    "command": "node",
374                    "idle_timeout": 5,
375                    "exclude_tools": ["secret_tool"]
376                }
377            }
378        }"#;
379        let cfg: McpConfig = serde_json::from_str(json).unwrap();
380        let legacy = cfg.mcp_servers.get("legacy").unwrap();
381        assert_eq!(legacy.idle_timeout, Some(5));
382        assert_eq!(
383            legacy.exclude_tools.as_deref(),
384            Some(&vec!["secret_tool".to_string()][..])
385        );
386    }
387
388    #[test]
389    fn round_trip_uses_camel_case_aliases() {
390        let mut cfg = McpConfig::default();
391        cfg.mcp_servers.insert(
392            "s".to_string(),
393            crate::mcp::types::ServerEntry {
394                command: Some("npx".to_string()),
395                idle_timeout: Some(7),
396                ..Default::default()
397            },
398        );
399        let s = serde_json::to_string(&cfg).unwrap();
400        assert!(s.contains("mcpServers"), "serialized key must be camelCase");
401        assert!(
402            s.contains("idleTimeout"),
403            "serialized field must be camelCase"
404        );
405        // And the round trip parses back.
406        let back: McpConfig = serde_json::from_str(&s).unwrap();
407        assert_eq!(back.mcp_servers["s"].idle_timeout, Some(7));
408    }
409
410    #[test]
411    fn save_and_load_round_trip() {
412        let dir = tempfile::tempdir().unwrap();
413        let path = dir.path().join("nested").join("mcp.json");
414        let mut cfg = McpConfig::default();
415        cfg.mcp_servers.insert(
416            "remote".to_string(),
417            crate::mcp::types::ServerEntry {
418                url: Some("https://example.com/mcp".to_string()),
419                idle_timeout: Some(3),
420                ..Default::default()
421            },
422        );
423        save_mcp_config(&path, &cfg).unwrap();
424        assert!(path.exists(), "temp file was renamed into place");
425        let loaded = load_or_default(&path);
426        assert_eq!(loaded.mcp_servers.len(), 1);
427        assert_eq!(
428            loaded.mcp_servers["remote"].url.as_deref(),
429            Some("https://example.com/mcp")
430        );
431        assert_eq!(loaded.mcp_servers["remote"].idle_timeout, Some(3));
432    }
433}