use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::Value as JsonValue;
use crate::userconfig::McpServerDef;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Claude,
Codex,
}
impl Source {
pub fn label(self) -> &'static str {
match self {
Source::Claude => "claude",
Source::Codex => "codex",
}
}
}
#[derive(Debug, Clone)]
pub struct Candidate {
pub name: String,
pub def: McpServerDef,
pub source: Source,
pub origin: String,
pub notes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SkippedEntry {
pub name: String,
pub source: Source,
pub reason: String,
}
#[derive(Debug, Default, Clone)]
pub struct ScanReport {
pub candidates: Vec<Candidate>,
pub skipped: Vec<SkippedEntry>,
}
#[derive(Debug, Default, Clone)]
pub struct ApplyOutcome {
pub imported: Vec<String>,
pub already_present: Vec<String>,
}
enum ParsedEntry {
Stdio(Box<McpServerDef>, Vec<String>),
Unsupported(String),
}
pub fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|h| !h.is_empty())
.map(PathBuf::from)
}
pub fn scan_all(home: &Path) -> ScanReport {
let mut entries = raw_claude_entries(home);
entries.extend(raw_codex_entries(home));
classify(entries)
}
fn classify(entries: Vec<(String, Source, String, ParsedEntry)>) -> ScanReport {
let mut report = ScanReport::default();
let mut seen: BTreeMap<String, Source> = BTreeMap::new();
for (name, source, origin, parsed) in entries {
if let Some(prev) = seen.get(&name) {
report.skipped.push(SkippedEntry {
name,
source,
reason: format!("duplicate server name (already found via {})", prev.label()),
});
continue;
}
match parsed {
ParsedEntry::Stdio(def, notes) => {
seen.insert(name.clone(), source);
report.candidates.push(Candidate {
name,
def: *def,
source,
origin,
notes,
});
}
ParsedEntry::Unsupported(reason) => {
report.skipped.push(SkippedEntry {
name,
source,
reason,
});
}
}
}
report
}
pub fn scan_claude(home: &Path) -> ScanReport {
classify(raw_claude_entries(home))
}
pub fn scan_codex(home: &Path) -> ScanReport {
classify(raw_codex_entries(home))
}
fn raw_claude_entries(home: &Path) -> Vec<(String, Source, String, ParsedEntry)> {
let mut out = Vec::new();
if let Some(root) = read_json(&home.join(".claude.json")) {
if let Some(obj) = root.get("mcpServers").and_then(JsonValue::as_object) {
let mut names: Vec<&String> = obj.keys().collect();
names.sort();
for name in names {
out.push(claude_entry(name, &obj[name], "~/.claude.json".to_string()));
}
}
if let Some(projects) = root.get("projects").and_then(JsonValue::as_object) {
let mut paths: Vec<&String> = projects.keys().collect();
paths.sort();
for path in paths {
if let Some(obj) = projects[path]
.get("mcpServers")
.and_then(JsonValue::as_object)
{
let mut names: Vec<&String> = obj.keys().collect();
names.sort();
for name in names {
out.push(claude_entry(
name,
&obj[name],
format!("~/.claude.json (project {path})"),
));
}
}
}
}
}
for (rel, label) in [
(
PathBuf::from(".claude").join("mcp.json"),
"~/.claude/mcp.json",
),
(PathBuf::from(".mcp.json"), "~/.mcp.json"),
] {
if let Some(root) = read_json(&home.join(&rel)) {
if let Some(obj) = root.get("mcpServers").and_then(JsonValue::as_object) {
let mut names: Vec<&String> = obj.keys().collect();
names.sort();
for name in names {
out.push(claude_entry(name, &obj[name], label.to_string()));
}
}
}
}
out
}
fn claude_entry(
name: &str,
v: &JsonValue,
origin: String,
) -> (String, Source, String, ParsedEntry) {
let ty = v.get("type").and_then(JsonValue::as_str).unwrap_or("stdio");
let parsed = if ty != "stdio" || v.get("url").is_some() {
ParsedEntry::Unsupported(format!(
"`{ty}` transport (url-based) not supported — supercode's MCP config is stdio-only"
))
} else {
match v.get("command").and_then(JsonValue::as_str) {
None => ParsedEntry::Unsupported("no `command` field".to_string()),
Some(command) => {
let args = v
.get("args")
.and_then(JsonValue::as_array)
.map(|a| {
a.iter()
.filter_map(JsonValue::as_str)
.map(String::from)
.collect()
})
.unwrap_or_default();
let env = v
.get("env")
.and_then(JsonValue::as_object)
.map(|o| {
o.iter()
.filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
.collect::<BTreeMap<String, String>>()
})
.filter(|m| !m.is_empty());
ParsedEntry::Stdio(
Box::new(McpServerDef {
command: Some(command.to_string()),
args,
env,
..Default::default()
}),
Vec::new(),
)
}
}
};
(name.to_string(), Source::Claude, origin, parsed)
}
fn raw_codex_entries(home: &Path) -> Vec<(String, Source, String, ParsedEntry)> {
let mut out = Vec::new();
let path = home.join(".codex").join("config.toml");
let Ok(text) = std::fs::read_to_string(&path) else {
return out;
};
let Ok(root) = text.parse::<toml::Value>() else {
return out;
};
if let Some(table) = root.get("mcp_servers").and_then(toml::Value::as_table) {
let mut names: Vec<&String> = table.keys().collect();
names.sort();
for name in names {
out.push(codex_entry(
name,
&table[name],
"~/.codex/config.toml".to_string(),
));
}
}
out
}
fn codex_entry(
name: &str,
v: &toml::Value,
origin: String,
) -> (String, Source, String, ParsedEntry) {
let parsed = if v.get("url").is_some() {
ParsedEntry::Unsupported(
"url-based transport not supported — supercode's MCP config is stdio-only".to_string(),
)
} else {
match v.get("command").and_then(toml::Value::as_str) {
None => ParsedEntry::Unsupported("no `command` field".to_string()),
Some(command) => {
let args = v
.get("args")
.and_then(toml::Value::as_array)
.map(|a| {
a.iter()
.filter_map(toml::Value::as_str)
.map(String::from)
.collect()
})
.unwrap_or_default();
let env = v
.get("env")
.and_then(toml::Value::as_table)
.map(|t| {
t.iter()
.filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
.collect::<BTreeMap<String, String>>()
})
.filter(|m| !m.is_empty());
ParsedEntry::Stdio(
Box::new(McpServerDef {
command: Some(command.to_string()),
args,
env,
..Default::default()
}),
Vec::new(),
)
}
}
};
(name.to_string(), Source::Codex, origin, parsed)
}
fn read_json(path: &Path) -> Option<JsonValue> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
pub fn apply(report: &ScanReport, servers: &mut BTreeMap<String, McpServerDef>) -> ApplyOutcome {
let mut outcome = ApplyOutcome::default();
for c in &report.candidates {
if servers.contains_key(&c.name) {
outcome.already_present.push(c.name.clone());
} else {
servers.insert(c.name.clone(), c.def.clone());
outcome.imported.push(c.name.clone());
}
}
outcome
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn write(dir: &Path, rel: &str, content: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, content).unwrap();
}
fn tempdir(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"supercode-mcpimport-{tag}-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
const CLAUDE_JSON: &str = r#"
{
"numStartups": 12,
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_TOKEN": "secret" }
},
"hosted": {
"type": "http",
"url": "https://example.com/mcp"
}
},
"projects": {
"/workspace/foo": {
"mcpServers": {
"project-only": { "command": "my-server", "args": ["--flag"] }
}
}
}
}
"#;
const CODEX_TOML: &str = r#"
[mcp_servers.filesystem]
command = "mcp-server-filesystem"
args = ["/tmp"]
[mcp_servers.filesystem.env]
FS_ROOT = "/tmp"
[mcp_servers.remote]
url = "https://example.com/mcp"
"#;
#[test]
fn parses_claude_top_level_and_per_project_stdio_servers() {
let home = tempdir("claude-shape");
write(&home, ".claude.json", CLAUDE_JSON);
let report = scan_claude(&home);
let names: Vec<&str> = report.candidates.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"github"), "names: {names:?}");
assert!(names.contains(&"project-only"), "names: {names:?}");
let github = report
.candidates
.iter()
.find(|c| c.name == "github")
.unwrap();
assert_eq!(github.def.command.as_deref(), Some("npx"));
assert_eq!(
github.def.args,
vec!["-y", "@modelcontextprotocol/server-github"]
);
assert_eq!(
github.def.env.as_ref().and_then(|e| e.get("GITHUB_TOKEN")),
Some(&"secret".to_string()),
"env: {:?}",
github.def.env
);
assert!(
github.notes.is_empty(),
"env is imported now, not dropped — no note expected: {:?}",
github.notes
);
let project_only = report
.candidates
.iter()
.find(|c| c.name == "project-only")
.unwrap();
assert!(
project_only.def.env.is_none(),
"no-env source entry must not fabricate an env map: {:?}",
project_only.def.env
);
let hosted = report.skipped.iter().find(|s| s.name == "hosted");
assert!(
hosted.is_some(),
"http entry must be recognized+skipped, not silently dropped"
);
assert!(hosted.unwrap().reason.contains("http"));
}
#[test]
fn parses_codex_toml_stdio_and_skips_url_entries() {
let home = tempdir("codex-shape");
write(&home, ".codex/config.toml", CODEX_TOML);
let report = scan_codex(&home);
let fs = report
.candidates
.iter()
.find(|c| c.name == "filesystem")
.expect("filesystem stdio server imported");
assert_eq!(fs.def.command.as_deref(), Some("mcp-server-filesystem"));
assert_eq!(fs.def.args, vec!["/tmp"]);
assert_eq!(
fs.def.env.as_ref().and_then(|e| e.get("FS_ROOT")),
Some(&"/tmp".to_string()),
"Codex TOML env table must be imported too: {:?}",
fs.def.env
);
let remote = report.skipped.iter().find(|s| s.name == "remote");
assert!(
remote.is_some(),
"url-based codex entry must be skipped with a reason"
);
}
#[test]
fn duplicate_names_across_sources_keep_the_first_and_report_the_rest() {
let home = tempdir("dup");
write(
&home,
".claude.json",
r#"{ "mcpServers": { "shared": { "command": "from-claude" } } }"#,
);
write(
&home,
".codex/config.toml",
"[mcp_servers.shared]\ncommand = \"from-codex\"\n",
);
let report = scan_all(&home);
let shared: Vec<&Candidate> = report
.candidates
.iter()
.filter(|c| c.name == "shared")
.collect();
assert_eq!(shared.len(), 1, "must not import the same name twice");
assert_eq!(
shared[0].def.command.as_deref(),
Some("from-claude"),
"claude scanned first, wins"
);
assert!(
report
.skipped
.iter()
.any(|s| s.name == "shared" && s.source == Source::Codex),
"the losing duplicate must be reported, not silently dropped"
);
}
#[test]
fn apply_is_idempotent_and_never_clobbers_existing_entries() {
let mut servers: BTreeMap<String, McpServerDef> = BTreeMap::new();
servers.insert(
"github".to_string(),
McpServerDef {
command: Some("user-configured-already".to_string()),
args: vec![],
env: None,
..Default::default()
},
);
let report = ScanReport {
candidates: vec![
Candidate {
name: "github".to_string(),
def: McpServerDef {
command: Some("npx".to_string()),
args: vec![],
env: None,
..Default::default()
},
source: Source::Claude,
origin: "~/.claude.json".to_string(),
notes: vec![],
},
Candidate {
name: "new-server".to_string(),
def: McpServerDef {
command: Some("my-server".to_string()),
args: vec![],
env: None,
..Default::default()
},
source: Source::Claude,
origin: "~/.claude.json".to_string(),
notes: vec![],
},
],
skipped: vec![],
};
let outcome = apply(&report, &mut servers);
assert_eq!(outcome.imported, vec!["new-server".to_string()]);
assert_eq!(outcome.already_present, vec!["github".to_string()]);
assert_eq!(
servers["github"].command.as_deref(),
Some("user-configured-already")
);
assert_eq!(servers["new-server"].command.as_deref(), Some("my-server"));
let outcome2 = apply(&report, &mut servers);
assert!(
outcome2.imported.is_empty(),
"second apply must not duplicate"
);
assert_eq!(
outcome2.already_present,
vec!["github".to_string(), "new-server".to_string()]
);
}
#[test]
fn no_source_files_yields_an_empty_report_not_an_error() {
let home = tempdir("empty");
let report = scan_all(&home);
assert!(report.candidates.is_empty());
assert!(report.skipped.is_empty());
}
}