1use std::collections::HashMap;
32
33use leviath_mcp::{MCPServerConfig, MCPTransport};
34
35#[derive(Debug, Clone)]
38pub struct Candidate {
39 pub config: MCPServerConfig,
41 pub scope: String,
44 pub inline_secrets: Vec<String>,
48}
49
50const SECRET_HINTS: [&str; 7] = [
52 "token", "key", "secret", "password", "passwd", "auth", "bearer",
53];
54
55fn looks_like_inline_secret(key: &str, value: &str) -> bool {
63 if value.is_empty() || value.starts_with("${") || value.starts_with('$') {
64 return false;
65 }
66 let lower = key.to_ascii_lowercase();
67 SECRET_HINTS.iter().any(|hint| lower.contains(hint))
68}
69
70fn inline_secrets(config: &MCPServerConfig) -> Vec<String> {
72 let mut found: Vec<String> = config
73 .env
74 .iter()
75 .chain(config.headers.iter())
76 .filter(|(k, v)| looks_like_inline_secret(k, v))
77 .map(|(k, _)| k.clone())
78 .collect();
79 found.sort();
80 found
81}
82
83fn string_map(value: Option<&serde_json::Value>) -> HashMap<String, String> {
87 value
88 .and_then(|v| v.as_object())
89 .map(|obj| {
90 obj.iter()
91 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
92 .collect()
93 })
94 .unwrap_or_default()
95}
96
97fn string_list(value: Option<&serde_json::Value>) -> Vec<String> {
99 value
100 .and_then(|v| v.as_array())
101 .map(|arr| {
102 arr.iter()
103 .filter_map(|v| v.as_str().map(str::to_owned))
104 .collect()
105 })
106 .unwrap_or_default()
107}
108
109fn is_disabled(entry: &serde_json::Map<String, serde_json::Value>) -> bool {
112 entry.get("enabled").and_then(|v| v.as_bool()) == Some(false)
113 || entry.get("disabled").and_then(|v| v.as_bool()) == Some(true)
114}
115
116pub fn parse_json_entry(name: &str, value: &serde_json::Value) -> Option<Candidate> {
126 let entry = value.as_object()?;
127 if is_disabled(entry) {
128 return None;
129 }
130
131 let nested = entry.get("command").and_then(|v| v.as_object());
134 let command_field = nested
135 .and_then(|c| c.get("path"))
136 .or_else(|| entry.get("command"));
137
138 let (command, mut args) = match command_field {
139 Some(serde_json::Value::String(s)) => (Some(s.clone()), Vec::new()),
140 Some(serde_json::Value::Array(_)) => {
142 let argv = string_list(entry.get("command"));
143 let mut it = argv.into_iter();
144 (it.next(), it.collect())
145 }
146 _ => (None, Vec::new()),
147 };
148 let declared_args = string_list(nested.map_or_else(|| entry.get("args"), |c| c.get("args")));
149 if !declared_args.is_empty() {
150 args = declared_args;
151 }
152
153 let url = ["url", "httpUrl", "serverUrl", "endpoint"]
154 .iter()
155 .find_map(|k| entry.get(*k).and_then(|v| v.as_str()))
156 .map(str::to_owned);
157
158 let mut env = string_map(nested.map_or_else(|| entry.get("env"), |c| c.get("env")));
159 env.extend(string_map(entry.get("environment")));
161 let headers = string_map(entry.get("headers"));
162
163 let config = match (url, command) {
164 (Some(url), _) => MCPServerConfig {
165 name: name.to_string(),
166 transport: Some(MCPTransport::Http),
167 url: Some(url),
168 headers,
169 ..MCPServerConfig::default()
170 },
171 (None, Some(command)) => MCPServerConfig {
172 name: name.to_string(),
173 transport: Some(MCPTransport::Stdio),
174 command: Some(command),
175 args,
176 env,
177 ..MCPServerConfig::default()
178 },
179 (None, None) => return None,
181 };
182
183 Some(Candidate {
193 inline_secrets: inline_secrets(&config),
194 config,
195 scope: String::new(),
196 })
197}
198
199fn parse_json_map(map: Option<&serde_json::Value>) -> Vec<Candidate> {
202 let Some(obj) = map.and_then(|v| v.as_object()) else {
203 return Vec::new();
204 };
205 let mut out: Vec<Candidate> = obj
206 .iter()
207 .filter_map(|(name, value)| parse_json_entry(name, value))
208 .collect();
209 out.sort_by(|a, b| a.config.name.cmp(&b.config.name));
210 out
211}
212
213pub fn parse_json_object(contents: &str, key: &str) -> anyhow::Result<Vec<Candidate>> {
219 let root: serde_json::Value = serde_json::from_str(contents)?;
220 Ok(parse_json_map(root.get(key)))
221}
222
223pub fn parse_claude_code(contents: &str) -> anyhow::Result<Vec<Candidate>> {
230 let root: serde_json::Value = serde_json::from_str(contents)?;
231 let mut out = parse_json_map(root.get("mcpServers"));
232
233 if let Some(projects) = root.get("projects").and_then(|v| v.as_object()) {
234 let mut paths: Vec<&String> = projects.keys().collect();
235 paths.sort();
236 for path in paths {
237 let scoped = parse_json_map(projects.get(path).and_then(|p| p.get("mcpServers")));
238 out.extend(scoped.into_iter().map(|mut c| {
239 c.scope = path.clone();
240 c
241 }));
242 }
243 }
244 Ok(out)
245}
246
247pub fn parse_codex(contents: &str) -> anyhow::Result<Vec<Candidate>> {
252 let root: serde_json::Value = toml::from_str(contents)?;
257 Ok(parse_json_map(root.get("mcp_servers")))
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn by_name<'a>(cands: &'a [Candidate], name: &str) -> &'a Candidate {
265 cands
266 .iter()
267 .find(|c| c.config.name == name)
268 .expect("candidate is present")
269 }
270
271 #[test]
274 fn parses_a_stdio_entry_with_args_and_env() {
275 let json = r#"{"mcpServers":{"fs":{"command":"npx","args":["-y","@mcp/fs"],
276 "env":{"ROOT":"/tmp"}}}}"#;
277
278 let found = parse_json_object(json, "mcpServers").unwrap();
279
280 assert_eq!(found.len(), 1);
281 let c = &found[0].config;
282 assert_eq!(c.name, "fs");
283 assert_eq!(c.transport, Some(MCPTransport::Stdio));
284 assert_eq!(c.command.as_deref(), Some("npx"));
285 assert_eq!(c.args, vec!["-y", "@mcp/fs"]);
286 assert_eq!(c.env.get("ROOT").map(String::as_str), Some("/tmp"));
287 assert!(c.url.is_none());
288 assert!(found[0].inline_secrets.is_empty());
289 }
290
291 #[test]
292 fn parses_an_opencode_argv_command() {
293 let json = r#"{"mcp":{"fs":{"type":"local","command":["npx","-y","@mcp/fs"],
296 "environment":{"ROOT":"/tmp"}}}}"#;
297
298 let found = parse_json_object(json, "mcp").unwrap();
299
300 let c = &by_name(&found, "fs").config;
301 assert_eq!(c.command.as_deref(), Some("npx"));
302 assert_eq!(c.args, vec!["-y", "@mcp/fs"]);
303 assert_eq!(c.env.get("ROOT").map(String::as_str), Some("/tmp"));
304 }
305
306 #[test]
307 fn parses_a_zed_nested_command_object() {
308 let json = r#"{"context_servers":{"fs":{"command":{"path":"npx",
309 "args":["-y","@mcp/fs"],"env":{"ROOT":"/tmp"}}}}}"#;
310
311 let found = parse_json_object(json, "context_servers").unwrap();
312
313 let c = &by_name(&found, "fs").config;
314 assert_eq!(c.command.as_deref(), Some("npx"));
315 assert_eq!(c.args, vec!["-y", "@mcp/fs"]);
316 assert_eq!(c.env.get("ROOT").map(String::as_str), Some("/tmp"));
317 }
318
319 #[test]
322 fn parses_http_entries_under_every_spelling_of_the_url_field() {
323 for key in ["url", "httpUrl", "serverUrl", "endpoint"] {
325 let json = format!(r#"{{"mcpServers":{{"api":{{"{key}":"https://x.test/mcp"}}}}}}"#);
326
327 let found = parse_json_object(&json, "mcpServers").unwrap();
328
329 let c = &by_name(&found, "api").config;
330 assert_eq!(c.transport, Some(MCPTransport::Http), "field {key}");
331 assert_eq!(c.url.as_deref(), Some("https://x.test/mcp"));
332 assert!(c.command.is_none());
333 }
334 }
335
336 #[test]
337 fn a_url_wins_over_a_command_so_the_entry_stays_resolvable() {
338 let json = r#"{"mcpServers":{"both":{"command":"npx","url":"https://x.test/mcp"}}}"#;
342
343 let found = parse_json_object(json, "mcpServers").unwrap();
344
345 let c = &by_name(&found, "both").config;
346 assert_eq!(c.url.as_deref(), Some("https://x.test/mcp"));
347 assert!(c.command.is_none());
348 assert!(c.resolve().is_ok());
349 }
350
351 #[test]
352 fn every_candidate_validates() {
353 let shapes = [
358 r#"{"s":{"a":{"command":"x"}}}"#,
359 r#"{"s":{"a":{"command":"x","args":["1"],"env":{"K":"V"}}}}"#,
360 r#"{"s":{"a":{"command":["npx","-y","p"]}}}"#,
361 r#"{"s":{"a":{"command":{"path":"npx","args":["-y"]}}}}"#,
362 r#"{"s":{"a":{"url":"https://y.test"}}}"#,
363 r#"{"s":{"a":{"httpUrl":"https://y.test","headers":{"H":"V"}}}}"#,
364 r#"{"s":{"a":{"serverUrl":"https://y.test"}}}"#,
365 r#"{"s":{"a":{"endpoint":"https://y.test"}}}"#,
366 r#"{"s":{"a":{"command":"x","url":"https://y.test"}}}"#,
367 ];
368
369 for shape in shapes {
370 let found = parse_json_object(shape, "s").unwrap();
371 assert_eq!(found.len(), 1, "shape produced no candidate: {shape}");
372 assert!(
373 found[0].config.validate().is_ok(),
374 "shape produced an invalid entry: {shape}"
375 );
376 assert!(found[0].config.resolve().is_ok());
377 }
378 }
379
380 #[test]
383 fn skips_entries_with_neither_a_command_nor_a_url() {
384 let json = r#"{"mcpServers":{"empty":{"description":"nothing to connect to"}}}"#;
385
386 assert!(parse_json_object(json, "mcpServers").unwrap().is_empty());
387 }
388
389 #[test]
390 fn skips_entries_the_other_harness_has_switched_off() {
391 let json = r#"{"mcpServers":{"off":{"command":"x","enabled":false},
393 "also-off":{"command":"y","disabled":true},
394 "on":{"command":"z","enabled":true}}}"#;
395
396 let found = parse_json_object(json, "mcpServers").unwrap();
397
398 assert_eq!(found.len(), 1);
399 assert_eq!(found[0].config.name, "on");
400 }
401
402 #[test]
403 fn skips_non_object_entries() {
404 let json = r#"{"mcpServers":{"bogus":"just a string","ok":{"command":"x"}}}"#;
405
406 let found = parse_json_object(json, "mcpServers").unwrap();
407
408 assert_eq!(found.len(), 1);
409 assert_eq!(found[0].config.name, "ok");
410 }
411
412 #[test]
413 fn a_missing_or_non_object_key_yields_nothing() {
414 assert!(parse_json_object("{}", "mcpServers").unwrap().is_empty());
415 assert!(
416 parse_json_object(r#"{"mcpServers":[]}"#, "mcpServers")
417 .unwrap()
418 .is_empty()
419 );
420 }
421
422 #[test]
423 fn malformed_json_is_an_error_not_a_silent_empty_list() {
424 let err = parse_json_object("{ // a comment\n }", "servers");
428
429 assert!(err.is_err());
430 }
431
432 #[test]
433 fn non_string_values_inside_env_and_args_are_dropped_not_fatal() {
434 let json = r#"{"mcpServers":{"x":{"command":"c","args":["a",7,"b"],
435 "env":{"GOOD":"1","BAD":2}}}}"#;
436
437 let found = parse_json_object(json, "mcpServers").unwrap();
438
439 let c = &found[0].config;
440 assert_eq!(c.args, vec!["a", "b"]);
441 assert_eq!(c.env.len(), 1);
442 assert_eq!(c.env.get("GOOD").map(String::as_str), Some("1"));
443 }
444
445 #[test]
448 fn flags_credential_shaped_env_and_header_values() {
449 let json = r#"{"mcpServers":{
450 "a":{"command":"x","env":{"API_TOKEN":"sk-live-123","ROOT":"/tmp"}},
451 "b":{"url":"https://y.test","headers":{"Authorization":"Bearer abc"}}}}"#;
452
453 let found = parse_json_object(json, "mcpServers").unwrap();
454
455 assert_eq!(by_name(&found, "a").inline_secrets, vec!["API_TOKEN"]);
456 assert_eq!(by_name(&found, "b").inline_secrets, vec!["Authorization"]);
457 }
458
459 #[test]
460 fn does_not_flag_env_references_or_innocuous_keys() {
461 let json = r#"{"mcpServers":{"a":{"command":"x","env":{
464 "API_TOKEN":"${GITHUB_TOKEN}","OTHER_KEY":"$SOME_VAR",
465 "EMPTY_SECRET":"","ROOT":"/tmp"}}}}"#;
466
467 let found = parse_json_object(json, "mcpServers").unwrap();
468
469 assert!(found[0].inline_secrets.is_empty());
470 }
471
472 #[test]
473 fn secret_detection_covers_every_hint_and_is_case_insensitive() {
474 for hint in SECRET_HINTS {
475 assert!(
476 looks_like_inline_secret(&format!("MY_{}", hint.to_uppercase()), "literal"),
477 "{hint} should be treated as credential-shaped"
478 );
479 }
480 assert!(!looks_like_inline_secret("ROOT_DIR", "literal"));
481 }
482
483 #[test]
486 fn claude_code_yields_global_and_per_project_servers_with_scopes() {
487 let json = r#"{
488 "mcpServers":{"global":{"url":"https://g.test/mcp"}},
489 "projects":{
490 "/repo/b":{"mcpServers":{"beta":{"command":"b"}}},
491 "/repo/a":{"mcpServers":{"alpha":{"command":"a"}}},
492 "/repo/c":{"other":"no servers here"}
493 }}"#;
494
495 let found = parse_claude_code(json).unwrap();
496
497 assert_eq!(found.len(), 3);
498 assert_eq!(found[0].config.name, "global");
499 assert!(found[0].scope.is_empty());
500 assert_eq!(found[1].config.name, "alpha");
502 assert_eq!(found[1].scope, "/repo/a");
503 assert_eq!(found[2].config.name, "beta");
504 assert_eq!(found[2].scope, "/repo/b");
505 }
506
507 #[test]
508 fn claude_code_tolerates_a_file_with_no_servers_at_all() {
509 assert!(
512 parse_claude_code(r#"{"numStartups":12,"projects":{}}"#)
513 .unwrap()
514 .is_empty()
515 );
516 assert!(
517 parse_claude_code(r#"{"numStartups":12}"#)
518 .unwrap()
519 .is_empty()
520 );
521 assert!(
522 parse_claude_code(r#"{"projects":"not an object"}"#)
523 .unwrap()
524 .is_empty()
525 );
526 }
527
528 #[test]
529 fn claude_code_rejects_malformed_json() {
530 assert!(parse_claude_code("not json").is_err());
531 }
532
533 #[test]
536 fn codex_parses_stdio_and_http_tables() {
537 let toml_src = r#"
538 [mcp_servers.fs]
539 command = "npx"
540 args = ["-y", "@mcp/fs"]
541
542 [mcp_servers.fs.env]
543 ROOT = "/tmp"
544
545 [mcp_servers.api]
546 url = "https://x.test/mcp"
547 "#;
548
549 let found = parse_codex(toml_src).unwrap();
550
551 assert_eq!(found.len(), 2);
552 let fs = &by_name(&found, "fs").config;
553 assert_eq!(fs.command.as_deref(), Some("npx"));
554 assert_eq!(fs.args, vec!["-y", "@mcp/fs"]);
555 assert_eq!(fs.env.get("ROOT").map(String::as_str), Some("/tmp"));
556 assert_eq!(
557 by_name(&found, "api").config.url.as_deref(),
558 Some("https://x.test/mcp")
559 );
560 }
561
562 #[test]
563 fn codex_without_an_mcp_servers_table_yields_nothing() {
564 assert!(parse_codex("model = \"gpt-5\"\n").unwrap().is_empty());
565 }
566
567 #[test]
568 fn codex_rejects_malformed_toml() {
569 assert!(parse_codex("[[[not toml").is_err());
570 }
571}