use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use toml_edit::{Array, DocumentMut, Item, Table, value};
const MCP_SERVERS_TABLE: &str = "mcp_servers";
pub fn codex_config_path(home: &Path) -> PathBuf {
home.join(".codex").join("config.toml")
}
pub fn patch_mcp_server(
path: &Path,
server_key: &str,
command: &str,
args: &[&str],
) -> Result<bool> {
let existing = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(e).with_context(|| format!("read Codex config {}", path.display()));
}
};
let mut doc: DocumentMut = existing
.parse()
.with_context(|| format!("parse Codex config {}", path.display()))?;
if server_entry_matches(&doc, server_key, command, args) {
return Ok(false);
}
let servers = doc
.entry(MCP_SERVERS_TABLE)
.or_insert_with(|| Item::Table(implicit_table()));
if servers.as_table().is_none() {
*servers = Item::Table(implicit_table());
}
let servers = servers
.as_table_mut()
.expect("mcp_servers coerced to a table above");
let entry = servers
.entry(server_key)
.or_insert_with(|| Item::Table(Table::new()));
if entry.as_table().is_none() {
*entry = Item::Table(Table::new());
}
let entry = entry
.as_table_mut()
.expect("server entry coerced to a table above");
entry["command"] = value(command);
let mut arg_array = Array::new();
for a in args {
arg_array.push(*a);
}
entry["args"] = value(arg_array);
write_atomic(path, &doc.to_string())?;
Ok(true)
}
fn server_entry_matches(doc: &DocumentMut, server_key: &str, command: &str, args: &[&str]) -> bool {
let Some(entry) = doc
.get(MCP_SERVERS_TABLE)
.and_then(Item::as_table)
.and_then(|t| t.get(server_key))
.and_then(Item::as_table)
else {
return false;
};
if entry.get("command").and_then(Item::as_str) != Some(command) {
return false;
}
let Some(existing) = entry.get("args").and_then(Item::as_array) else {
return false;
};
existing.len() == args.len()
&& existing
.iter()
.zip(args)
.all(|(got, want)| got.as_str() == Some(*want))
}
fn implicit_table() -> Table {
let mut t = Table::new();
t.set_implicit(true);
t
}
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("create parent dir {}", parent.display()))?;
}
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
std::fs::write(&tmp, contents.as_bytes())
.with_context(|| format!("write temp file {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} onto {}", tmp.display(), path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_config_path_is_under_dot_codex() {
let p = codex_config_path(Path::new("/Users/x"));
assert_eq!(p, PathBuf::from("/Users/x/.codex/config.toml"));
}
#[test]
fn patch_mcp_server_creates_missing_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = codex_config_path(tmp.path());
let wrote = patch_mcp_server(&path, "trusty-search", "trusty-search", &["serve"])
.expect("patch a missing config");
assert!(wrote, "a fresh registration is a write");
let doc: DocumentMut = std::fs::read_to_string(&path)
.expect("config written")
.parse()
.expect("valid TOML");
let entry = doc["mcp_servers"]["trusty-search"]
.as_table()
.expect("server table");
assert_eq!(entry["command"].as_str(), Some("trusty-search"));
let args: Vec<&str> = entry["args"]
.as_array()
.expect("args array")
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(args, vec!["serve"], "the MCP entrypoint is `serve`");
}
#[test]
fn patch_mcp_server_is_idempotent() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = codex_config_path(tmp.path());
assert!(patch_mcp_server(&path, "trusty-search", "trusty-search", &["serve"]).unwrap());
let first = std::fs::read_to_string(&path).expect("read");
assert!(
!patch_mcp_server(&path, "trusty-search", "trusty-search", &["serve"]).unwrap(),
"an unchanged registration must not report a write"
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), first);
}
#[test]
fn patch_mcp_server_repairs_empty_args() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = codex_config_path(tmp.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"[mcp_servers.trusty-search]\ncommand = \"trusty-search\"\nargs = []\n",
)
.unwrap();
assert!(
patch_mcp_server(&path, "trusty-search", "trusty-search", &["serve"]).unwrap(),
"an empty argument vector must be repaired, not left alone"
);
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("\"serve\"")
);
}
#[test]
fn patch_mcp_server_repairs_nested_json_string_args() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = codex_config_path(tmp.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"[mcp_servers.trusty-search]\ncommand = \"trusty-search\"\nargs = [\"[\\\"serve\\\"]\"]\n",
)
.unwrap();
assert!(
patch_mcp_server(&path, "trusty-search", "trusty-search", &["serve"]).unwrap(),
"a JSON-looking single argument must be repaired"
);
let doc: DocumentMut = std::fs::read_to_string(&path).unwrap().parse().unwrap();
let args: Vec<&str> = doc["mcp_servers"]["trusty-search"]["args"]
.as_array()
.expect("args array")
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(args, vec!["serve"], "got {args:?}");
}
#[test]
fn patch_mcp_server_preserves_other_servers_and_comments() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = codex_config_path(tmp.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"# my codex config\nmodel = \"gpt-5\"\n\n\
[mcp_servers.other]\ncommand = \"other-server\"\nargs = [\"run\"]\n",
)
.unwrap();
assert!(patch_mcp_server(&path, "trusty-search", "trusty-search", &["serve"]).unwrap());
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("# my codex config"), "comment lost:\n{text}");
assert!(text.contains("model = \"gpt-5\""), "setting lost:\n{text}");
let doc: DocumentMut = text.parse().unwrap();
assert_eq!(
doc["mcp_servers"]["other"]["command"].as_str(),
Some("other-server"),
"another server's registration was clobbered"
);
assert_eq!(
doc["mcp_servers"]["trusty-search"]["command"].as_str(),
Some("trusty-search")
);
}
}