use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Transport {
Stdio {
command: String,
args: Vec<String>,
},
Http {
url: String,
bearer_token_env_var: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpServerConfig {
transport: Transport,
env: BTreeMap<String, String>,
}
impl McpServerConfig {
#[must_use]
pub fn stdio(command: impl Into<String>) -> Self {
Self {
transport: Transport::Stdio {
command: command.into(),
args: Vec::new(),
},
env: BTreeMap::new(),
}
}
#[must_use]
pub fn http(url: impl Into<String>) -> Self {
Self {
transport: Transport::Http {
url: url.into(),
bearer_token_env_var: None,
},
env: BTreeMap::new(),
}
}
#[must_use]
pub fn arg(mut self, value: impl Into<String>) -> Self {
if let Transport::Stdio { args, .. } = &mut self.transport {
args.push(value.into());
}
self
}
#[must_use]
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
#[must_use]
pub fn bearer_token_env_var(mut self, env_var: impl Into<String>) -> Self {
if let Transport::Http {
bearer_token_env_var,
..
} = &mut self.transport
{
*bearer_token_env_var = Some(env_var.into());
}
self
}
fn fields(&self) -> Vec<(String, String)> {
let mut out = Vec::new();
match &self.transport {
Transport::Stdio { command, args } => {
out.push(("command".into(), toml_string(command)));
if !args.is_empty() {
let items: Vec<String> = args.iter().map(|a| toml_string(a)).collect();
out.push(("args".into(), format!("[{}]", items.join(","))));
}
}
Transport::Http {
url,
bearer_token_env_var,
} => {
out.push(("url".into(), toml_string(url)));
if let Some(var) = bearer_token_env_var {
out.push(("bearer_token_env_var".into(), toml_string(var)));
}
}
}
if !self.env.is_empty() {
let pairs: Vec<String> = self
.env
.iter()
.map(|(k, v)| format!("{}={}", toml_key(k), toml_string(v)))
.collect();
out.push(("env".into(), format!("{{{}}}", pairs.join(","))));
}
out
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct McpConfigBuilder {
servers: BTreeMap<String, McpServerConfig>,
}
impl McpConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn server(mut self, name: impl Into<String>, config: McpServerConfig) -> Self {
self.servers.insert(name.into(), config);
self
}
#[must_use]
pub fn stdio_server(self, name: impl Into<String>, command: impl Into<String>) -> Self {
self.server(name, McpServerConfig::stdio(command))
}
#[must_use]
pub fn http_server(self, name: impl Into<String>, url: impl Into<String>) -> Self {
self.server(name, McpServerConfig::http(url))
}
#[must_use]
pub fn config_overrides(&self) -> Vec<String> {
self.servers
.iter()
.flat_map(|(name, config)| {
config.fields().into_iter().map(move |(key, value)| {
format!("mcp_servers.{}.{key}={value}", toml_key(name))
})
})
.collect()
}
#[must_use]
pub fn to_toml(&self) -> String {
let mut out = String::new();
for (name, config) in &self.servers {
out.push_str(&format!("[mcp_servers.{}]\n", toml_key(name)));
for (key, value) in config.fields() {
out.push_str(&format!("{key} = {value}\n"));
}
out.push('\n');
}
out
}
pub fn write_profile(&self, codex_home: impl AsRef<Path>, profile: &str) -> Result<PathBuf> {
let path = codex_home.as_ref().join(format!("{profile}.config.toml"));
std::fs::write(&path, self.to_toml()).map_err(|e| Error::Io {
message: format!("failed to write {}: {e}", path.display()),
source: e,
working_dir: Some(codex_home.as_ref().to_path_buf()),
})?;
Ok(path)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.servers.is_empty()
}
}
fn toml_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn toml_key(key: &str) -> String {
let bare = !key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if bare {
key.to_string()
} else {
toml_string(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overrides_match_the_verified_forms() {
let mcp = McpConfigBuilder::new()
.server(
"files",
McpServerConfig::stdio("npx").arg("-y").arg("server"),
)
.server(
"docs",
McpServerConfig::http("https://example.com/mcp").bearer_token_env_var("TOKEN"),
);
assert_eq!(
mcp.config_overrides(),
vec![
r#"mcp_servers.docs.url="https://example.com/mcp""#,
r#"mcp_servers.docs.bearer_token_env_var="TOKEN""#,
r#"mcp_servers.files.command="npx""#,
r#"mcp_servers.files.args=["-y","server"]"#,
]
);
}
#[test]
fn env_becomes_an_inline_table() {
let mcp = McpConfigBuilder::new().server(
"files",
McpServerConfig::stdio("run").env("B", "2").env("A", "1"),
);
assert_eq!(
mcp.config_overrides(),
vec![
r#"mcp_servers.files.command="run""#,
r#"mcp_servers.files.env={A="1",B="2"}"#,
],
"entries are ordered, so the same set produces the same arguments"
);
}
#[test]
fn values_are_escaped() {
let mcp = McpConfigBuilder::new().server(
"s",
McpServerConfig::stdio(r#"say "hi""#)
.arg("back\\slash")
.arg("two\nlines"),
);
let overrides = mcp.config_overrides();
assert_eq!(overrides[0], r#"mcp_servers.s.command="say \"hi\"""#);
assert_eq!(
overrides[1],
r#"mcp_servers.s.args=["back\\slash","two\nlines"]"#
);
}
#[test]
fn a_name_needing_quotes_gets_them() {
let mcp = McpConfigBuilder::new().stdio_server("my.server", "run");
assert_eq!(
mcp.config_overrides(),
vec![r#"mcp_servers."my.server".command="run""#]
);
}
#[test]
fn args_and_bearer_token_apply_only_where_they_belong() {
let http =
McpConfigBuilder::new().server("h", McpServerConfig::http("https://x").arg("-y"));
assert_eq!(
http.config_overrides(),
vec![r#"mcp_servers.h.url="https://x""#]
);
let stdio = McpConfigBuilder::new()
.server("s", McpServerConfig::stdio("run").bearer_token_env_var("T"));
assert_eq!(
stdio.config_overrides(),
vec![r#"mcp_servers.s.command="run""#]
);
}
#[test]
fn to_toml_produces_a_profile_document() {
let mcp = McpConfigBuilder::new().server("files", McpServerConfig::stdio("npx").arg("-y"));
assert_eq!(
mcp.to_toml(),
"[mcp_servers.files]\ncommand = \"npx\"\nargs = [\"-y\"]\n\n"
);
}
#[test]
fn write_profile_lands_where_profile_would_look() {
let home = std::env::temp_dir().join(format!("codex-wrapper-mcp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let path = McpConfigBuilder::new()
.stdio_server("files", "npx")
.write_profile(&home, "isolated")
.unwrap();
assert_eq!(path, home.join("isolated.config.toml"));
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("[mcp_servers.files]"), "{written}");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn an_empty_builder_produces_nothing() {
let mcp = McpConfigBuilder::new();
assert!(mcp.is_empty());
assert!(mcp.config_overrides().is_empty());
assert_eq!(mcp.to_toml(), "");
}
}