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> {
supercode_interchange::user_home()
.map(std::path::PathBuf::into_os_string)
.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 — Volter Harness'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 — Volter Harness'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
}