use crate::adapter::{Capability, Render};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub fn document(cap: Capability, render: Render, sources: &[PathBuf]) -> Result<String> {
match render {
Render::McpJson | Render::CodexToml | Render::OpencodeJson => {
mcp(render, &merge_servers(sources)?)
}
Render::ClaudeSettings => claude_settings(&merge_hooks(sources)?),
Render::Dir | Render::Concat => {
anyhow::bail!("{cap}: `{render:?}` is staged by the launcher, not rendered")
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Server {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
#[derive(Deserialize)]
struct CanonicalMcp {
#[serde(rename = "mcpServers", default)]
servers: BTreeMap<String, Server>,
}
pub fn parse_layers(files: &[PathBuf]) -> Result<BTreeMap<String, Server>> {
merge_servers(files)
}
fn merge_servers(files: &[PathBuf]) -> Result<BTreeMap<String, Server>> {
let mut out = BTreeMap::new();
for f in files {
let parsed: CanonicalMcp = read_json(f)?;
out.extend(parsed.servers);
}
Ok(out)
}
fn mcp(render: Render, servers: &BTreeMap<String, Server>) -> Result<String> {
match render {
Render::McpJson => pretty(serde_json::json!({ "mcpServers": servers })),
Render::OpencodeJson => {
let mcp: BTreeMap<_, _> = servers
.iter()
.map(|(name, s)| {
let mut command = vec![s.command.clone()];
command.extend(s.args.iter().cloned());
(
name.clone(),
serde_json::json!({
"type": "local",
"command": command,
"environment": s.env,
"enabled": true,
}),
)
})
.collect();
pretty(serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"mcp": mcp
}))
}
Render::CodexToml => {
let mut out = String::new();
for (name, s) in servers {
out.push_str(&format!("[mcp_servers.{name}]\n"));
out.push_str(&format!("command = {}\n", toml_str(&s.command)));
let args: Vec<String> = s.args.iter().map(|a| toml_str(a)).collect();
out.push_str(&format!("args = [{}]\n", args.join(", ")));
if !s.env.is_empty() {
out.push_str(&format!("\n[mcp_servers.{name}.env]\n"));
for (k, v) in &s.env {
out.push_str(&format!("{k} = {}\n", toml_str(v)));
}
}
out.push('\n');
}
Ok(out)
}
_ => unreachable!("caller matched on MCP renders"),
}
}
pub fn parse(format: Render, raw: &str) -> Result<BTreeMap<String, Server>> {
match format {
Render::McpJson => {
let doc: serde_json::Value = serde_json::from_str(raw).context("parsing MCP JSON")?;
if doc.get("mcpServers").is_none() && doc.get("projects").is_some() {
anyhow::bail!(
"this config nests servers under `projects` — importing all of \
them would pull in servers from unrelated repos. Point --file \
at a project-scoped .mcp.json instead."
);
}
let doc: CanonicalMcp = serde_json::from_value(doc).context("reading mcpServers")?;
Ok(doc.servers)
}
Render::CodexToml => {
#[derive(Deserialize)]
struct Doc {
#[serde(default, rename = "mcp_servers")]
servers: BTreeMap<String, Server>,
}
let doc: Doc = toml::from_str(raw).context("parsing codex config.toml")?;
Ok(doc.servers)
}
Render::OpencodeJson => {
#[derive(Deserialize)]
struct Doc {
#[serde(default)]
mcp: BTreeMap<String, Entry>,
}
#[derive(Deserialize)]
struct Entry {
#[serde(default)]
command: Vec<String>,
#[serde(default)]
environment: BTreeMap<String, String>,
}
let doc: Doc = serde_json::from_str(raw).context("parsing opencode.json")?;
doc.mcp
.into_iter()
.map(|(name, e)| {
let (command, args) = e
.command
.split_first()
.with_context(|| format!("server `{name}` has an empty command"))?;
Ok((
name,
Server {
command: command.clone(),
args: args.to_vec(),
env: e.environment,
},
))
})
.collect()
}
other => anyhow::bail!("`{other:?}` is not an MCP format and cannot be imported"),
}
}
#[derive(Debug, Deserialize)]
struct Hook {
event: String,
#[serde(default)]
matcher: String,
command: String,
}
fn merge_hooks(dirs: &[PathBuf]) -> Result<BTreeMap<String, Hook>> {
let mut out = BTreeMap::new();
for dir in dirs {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
out.insert(
entry.file_name().to_string_lossy().into_owned(),
read_json(&path)?,
);
}
}
}
Ok(out)
}
fn claude_settings(hooks: &BTreeMap<String, Hook>) -> Result<String> {
let mut by_event: BTreeMap<&str, Vec<serde_json::Value>> = BTreeMap::new();
for h in hooks.values() {
by_event
.entry(&h.event)
.or_default()
.push(serde_json::json!({
"matcher": h.matcher,
"hooks": [{ "type": "command", "command": h.command }],
}));
}
pretty(serde_json::json!({ "hooks": by_event }))
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
let raw =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
}
fn pretty(v: serde_json::Value) -> Result<String> {
Ok(serde_json::to_string_pretty(&v)?)
}
fn toml_str(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn file(dir: &Path, name: &str, body: &str) -> PathBuf {
let p = dir.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
write!(std::fs::File::create(&p).unwrap(), "{body}").unwrap();
p
}
fn servers(json: &[(&str, &str)]) -> (tempfile::TempDir, Vec<PathBuf>) {
let dir = tempfile::tempdir().unwrap();
let paths = json
.iter()
.enumerate()
.map(|(i, (_, body))| file(dir.path(), &format!("l{i}.json"), body))
.collect();
(dir, paths)
}
const L1: &str = r#"{"mcpServers":{"a":{"command":"a-cmd","args":["--x"]}}}"#;
const L2: &str = r#"{"mcpServers":{"b":{"command":"b-cmd","env":{"K":"v"}}}}"#;
const L2_SHADOW: &str = r#"{"mcpServers":{"a":{"command":"overridden"}}}"#;
#[test]
fn mcp_merges_across_layers() {
let (_d, files) = servers(&[("", L1), ("", L2)]);
let merged = merge_servers(&files).unwrap();
assert_eq!(merged.len(), 2);
assert_eq!(merged["a"].command, "a-cmd");
assert_eq!(merged["b"].env["K"], "v");
}
#[test]
fn later_layers_shadow_earlier_ones() {
let (_d, files) = servers(&[("", L1), ("", L2_SHADOW)]);
let merged = merge_servers(&files).unwrap();
assert_eq!(
merged["a"].command, "overridden",
"layer 3 must win over layer 1"
);
}
#[test]
fn each_format_emits_its_harness_shape() {
let (_d, files) = servers(&[("", L1)]);
let m = merge_servers(&files).unwrap();
let claude = mcp(Render::McpJson, &m).unwrap();
let v: serde_json::Value = serde_json::from_str(&claude).unwrap();
assert_eq!(v["mcpServers"]["a"]["command"], "a-cmd");
let oc = mcp(Render::OpencodeJson, &m).unwrap();
let v: serde_json::Value = serde_json::from_str(&oc).unwrap();
assert_eq!(v["mcp"]["a"]["type"], "local");
assert_eq!(v["mcp"]["a"]["command"][0], "a-cmd");
assert_eq!(v["mcp"]["a"]["command"][1], "--x");
let codex = mcp(Render::CodexToml, &m).unwrap();
assert!(codex.contains("[mcp_servers.a]"), "got: {codex}");
assert!(codex.contains(r#"command = "a-cmd""#), "got: {codex}");
let reparsed: toml::Value = codex.parse().expect("codex output must be valid TOML");
assert_eq!(
reparsed["mcp_servers"]["a"]["args"][0].as_str(),
Some("--x")
);
}
#[test]
fn codex_toml_escapes_quotes() {
let mut m = BTreeMap::new();
m.insert(
"q".to_string(),
Server {
command: r#"say "hi""#.into(),
args: vec![],
env: BTreeMap::new(),
},
);
let out = mcp(Render::CodexToml, &m).unwrap();
out.parse::<toml::Value>()
.expect("must stay valid TOML when values contain quotes");
}
#[test]
fn hooks_group_by_event() {
let dir = tempfile::tempdir().unwrap();
file(
dir.path(),
"h/a.json",
r#"{"event":"Stop","command":"one"}"#,
);
file(
dir.path(),
"h/b.json",
r#"{"event":"Stop","command":"two"}"#,
);
file(
dir.path(),
"h/c.json",
r#"{"event":"PostToolUse","matcher":"Edit","command":"three"}"#,
);
let hooks = merge_hooks(&[dir.path().join("h")]).unwrap();
let out = claude_settings(&hooks).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["hooks"]["Stop"].as_array().unwrap().len(), 2);
assert_eq!(v["hooks"]["PostToolUse"][0]["matcher"], "Edit");
assert_eq!(v["hooks"]["PostToolUse"][0]["hooks"][0]["type"], "command");
}
#[test]
fn staged_renders_are_not_documents() {
let err = document(Capability::Skills, Render::Dir, &[]).unwrap_err();
assert!(err.to_string().contains("staged by the launcher"));
}
#[test]
fn malformed_json_names_the_file() {
let dir = tempfile::tempdir().unwrap();
let bad = file(dir.path(), "broken.json", "{ not json");
let err = merge_servers(&[bad]).unwrap_err();
assert!(err.to_string().contains("broken.json"), "got: {err}");
}
fn canonical() -> BTreeMap<String, Server> {
let mut m = BTreeMap::new();
m.insert(
"plain".to_string(),
Server {
command: "plain-cmd".into(),
args: vec![],
env: BTreeMap::new(),
},
);
m.insert(
"rich".to_string(),
Server {
command: "rich-cmd".into(),
args: vec!["--root".into(), "/work".into()],
env: BTreeMap::from([("TOKEN".to_string(), "abc".to_string())]),
},
);
m
}
#[test]
fn every_mcp_format_round_trips() {
let original = canonical();
for format in [Render::McpJson, Render::CodexToml, Render::OpencodeJson] {
let rendered = mcp(format, &original).unwrap();
let back = parse(format, &rendered)
.unwrap_or_else(|e| panic!("{format:?} failed to parse its own output: {e:#}"));
assert_eq!(back, original, "{format:?} lost data on round-trip");
}
}
#[test]
fn opencode_command_array_splits_back_into_command_and_args() {
let raw = r#"{"mcp":{"g":{"type":"local","command":["cmd","--a","--b"]}}}"#;
let back = parse(Render::OpencodeJson, raw).unwrap();
assert_eq!(back["g"].command, "cmd");
assert_eq!(back["g"].args, ["--a", "--b"]);
}
#[test]
fn parses_a_hand_written_mcp_json() {
let raw = r#"{"mcpServers":{"g":{"command":"c","args":["x"],"env":{"K":"v"}}}}"#;
let back = parse(Render::McpJson, raw).unwrap();
assert_eq!(back["g"].args, ["x"]);
assert_eq!(back["g"].env["K"], "v");
}
#[test]
fn parses_a_hand_written_codex_toml() {
let raw =
"[mcp_servers.g]\ncommand = \"c\"\nargs = [\"x\"]\n\n[mcp_servers.g.env]\nK = \"v\"\n";
let back = parse(Render::CodexToml, raw).unwrap();
assert_eq!(back["g"].command, "c");
assert_eq!(back["g"].env["K"], "v");
}
#[test]
fn empty_config_parses_to_no_servers() {
assert!(parse(Render::McpJson, "{}").unwrap().is_empty());
assert!(parse(Render::CodexToml, "").unwrap().is_empty());
assert!(parse(Render::OpencodeJson, "{}").unwrap().is_empty());
}
#[test]
fn project_nested_claude_config_is_refused_with_guidance() {
let raw = r#"{"projects":{"/some/repo":{"mcpServers":{"g":{"command":"c"}}}}}"#;
let err = parse(Render::McpJson, raw).unwrap_err();
assert!(format!("{err:#}").contains("projects"), "got: {err:#}");
}
#[test]
fn malformed_input_is_an_error_not_an_empty_import() {
assert!(parse(Render::McpJson, "{ not json").is_err());
assert!(parse(Render::CodexToml, "[[[").is_err());
}
#[test]
fn non_mcp_formats_cannot_be_parsed_as_servers() {
assert!(parse(Render::Dir, "").is_err());
assert!(parse(Render::ClaudeSettings, "{}").is_err());
}
}