oxicode_agent/mcp/
config.rs1use super::types::McpConfig;
10use anyhow::{Context, Result};
11use std::path::{Path, PathBuf};
12
13pub fn config_paths(cwd: &Path) -> Vec<PathBuf> {
15 let mut paths = Vec::new();
16
17 if let Some(config_dir) = dirs::config_dir() {
19 paths.push(config_dir.join("mcp").join("mcp.json"));
20 }
21
22 if let Some(config_dir) = dirs::config_dir() {
24 paths.push(config_dir.join("oxicode").join("mcp.json"));
25 }
26
27 paths.push(cwd.join(".mcp.json"));
29
30 paths.push(cwd.join(".oxicode").join("mcp.json"));
32
33 paths
34}
35
36pub 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
51pub 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
63pub 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 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
103fn 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
114pub 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
135pub fn default_write_path_global() -> Option<PathBuf> {
141 dirs::config_dir().map(|d| d.join("oxicode").join("mcp.json"))
142}
143
144pub fn default_write_path_project(cwd: &Path) -> PathBuf {
147 cwd.join(".oxicode").join("mcp.json")
148}
149
150pub fn load_or_default(path: &Path) -> McpConfig {
154 read_config_file(path).unwrap_or_default()
155}
156
157pub 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 #[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
303pub 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 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 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}