use std::path::{Path, PathBuf};
use toml::Value;
use crate::errors::{McpError, McpErrorKind};
use crate::install::{InstallOpts, Scope};
pub fn write(opts: &InstallOpts) -> Result<(), McpError> {
let path = config_path(&opts.scope)?;
let name = opts.entry_name();
debug_assert!(
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"name '{name}' should have been validated by dispatch"
);
let mut doc = load_or_create_toml(&path)?;
let mcp_servers = ensure_mcp_servers_table(&mut doc)?;
if opts.uninstall {
if mcp_servers.remove(&name).is_some() {
tracing::info!(target: "tsafe_mcp::install", "removed [mcp_servers.{name}] from {}", path.display());
}
} else {
let mut entry = toml::value::Table::new();
entry.insert(
"command".to_string(),
Value::String("tsafe-mcp".to_string()),
);
entry.insert(
"args".to_string(),
Value::Array(opts.server_args().into_iter().map(Value::String).collect()),
);
if let Ok(sock) = std::env::var("TSAFE_AGENT_SOCK") {
if !sock.is_empty() {
let mut env = toml::value::Table::new();
env.insert("TSAFE_AGENT_SOCK".to_string(), Value::String(sock));
entry.insert("env".to_string(), Value::Table(env));
}
}
mcp_servers.insert(name, Value::Table(entry));
}
if opts.dry_run {
let s = toml::to_string_pretty(&doc).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("serialize Codex TOML: {e}"),
)
})?;
println!("# dry-run: would write {}", path.display());
println!("{s}");
return Ok(());
}
persist_atomically(&path, &doc)
}
pub fn config_path(scope: &Scope) -> Result<PathBuf, McpError> {
match scope {
Scope::Project { dir } => Ok(dir.join(".codex").join("config.toml")),
Scope::Global => {
let home = directories::BaseDirs::new()
.map(|b| b.home_dir().to_path_buf())
.ok_or_else(|| {
McpError::new(
McpErrorKind::InternalError,
"could not resolve home directory",
)
})?;
Ok(home.join(".codex").join("config.toml"))
}
}
}
fn load_or_create_toml(path: &Path) -> Result<Value, McpError> {
if !path.exists() {
return Ok(Value::Table(toml::value::Table::new()));
}
let raw = std::fs::read_to_string(path).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("read {}: {e}", path.display()),
)
})?;
if raw.trim().is_empty() {
return Ok(Value::Table(toml::value::Table::new()));
}
toml::from_str::<Value>(&raw).map_err(|e| {
McpError::new(
McpErrorKind::InstallConfigMalformed,
format!(
"existing Codex config at {} is not valid TOML: {e}",
path.display()
),
)
})
}
fn ensure_mcp_servers_table(doc: &mut Value) -> Result<&mut toml::value::Table, McpError> {
let root = doc.as_table_mut().ok_or_else(|| {
McpError::new(
McpErrorKind::InstallConfigMalformed,
"Codex config root is not a TOML table",
)
})?;
if !matches!(root.get("mcp_servers"), Some(Value::Table(_))) {
root.insert(
"mcp_servers".to_string(),
Value::Table(toml::value::Table::new()),
);
}
let table = root.get_mut("mcp_servers").expect("just inserted");
table.as_table_mut().ok_or_else(|| {
McpError::new(
McpErrorKind::InstallConfigMalformed,
"[mcp_servers] exists but is not a TOML table",
)
})
}
fn persist_atomically(path: &Path, doc: &Value) -> Result<(), McpError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("create_dir_all {}: {e}", parent.display()),
)
})?;
}
let pretty = toml::to_string_pretty(doc)
.map_err(|e| McpError::new(McpErrorKind::InternalError, format!("serialize TOML: {e}")))?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, &pretty).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("write {}: {e}", tmp.display()),
)
})?;
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
McpError::new(
McpErrorKind::InternalError,
format!("rename to {}: {e}", path.display()),
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::install::Scope;
fn opts_for(tmp_path: &Path, name: &str, uninstall: bool) -> InstallOpts {
InstallOpts {
profile: "demo".to_string(),
allowed_keys: vec!["demo/*".to_string()],
denied_keys: vec![],
contract: None,
allow_reveal: false,
name: Some(name.to_string()),
scope: Scope::Project {
dir: tmp_path.to_path_buf(),
},
dry_run: false,
uninstall,
audit_source: None,
}
}
#[test]
fn merge_with_existing_other_entry() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".codex").join("config.toml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"[mcp_servers.other]
command = "x"
args = []
"#,
)
.unwrap();
write(&opts_for(tmp.path(), "testsrv", false)).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let v: Value = toml::from_str(&raw).unwrap();
let servers = v["mcp_servers"].as_table().unwrap();
assert!(servers.contains_key("other"), "other must survive");
assert!(servers.contains_key("testsrv"), "new entry present");
assert_eq!(servers["testsrv"]["command"].as_str(), Some("tsafe-mcp"));
}
#[test]
fn malformed_toml_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".codex").join("config.toml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "this is not toml ===").unwrap();
let err = write(&opts_for(tmp.path(), "testsrv", false)).unwrap_err();
assert_eq!(err.kind, McpErrorKind::InstallConfigMalformed);
}
#[test]
fn uninstall_removes_only_named_entry() {
let tmp = tempfile::tempdir().unwrap();
write(&opts_for(tmp.path(), "alpha", false)).unwrap();
write(&opts_for(tmp.path(), "beta", false)).unwrap();
let path = tmp.path().join(".codex").join("config.toml");
let v: Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(v["mcp_servers"]["alpha"].is_table());
assert!(v["mcp_servers"]["beta"].is_table());
write(&opts_for(tmp.path(), "alpha", true)).unwrap();
let v: Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(v["mcp_servers"].get("alpha").is_none());
assert!(v["mcp_servers"]["beta"].is_table());
}
}