1use std::collections::BTreeMap;
55use std::path::{Path, PathBuf};
56
57use crate::error::{Error, Result};
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61enum Transport {
62 Stdio {
63 command: String,
64 args: Vec<String>,
65 },
66 Http {
67 url: String,
68 bearer_token_env_var: Option<String>,
69 },
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct McpServerConfig {
78 transport: Transport,
79 env: BTreeMap<String, String>,
80}
81
82impl McpServerConfig {
83 #[must_use]
85 pub fn stdio(command: impl Into<String>) -> Self {
86 Self {
87 transport: Transport::Stdio {
88 command: command.into(),
89 args: Vec::new(),
90 },
91 env: BTreeMap::new(),
92 }
93 }
94
95 #[must_use]
97 pub fn http(url: impl Into<String>) -> Self {
98 Self {
99 transport: Transport::Http {
100 url: url.into(),
101 bearer_token_env_var: None,
102 },
103 env: BTreeMap::new(),
104 }
105 }
106
107 #[must_use]
109 pub fn arg(mut self, value: impl Into<String>) -> Self {
110 if let Transport::Stdio { args, .. } = &mut self.transport {
111 args.push(value.into());
112 }
113 self
114 }
115
116 #[must_use]
118 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
119 self.env.insert(key.into(), value.into());
120 self
121 }
122
123 #[must_use]
129 pub fn bearer_token_env_var(mut self, env_var: impl Into<String>) -> Self {
130 if let Transport::Http {
131 bearer_token_env_var,
132 ..
133 } = &mut self.transport
134 {
135 *bearer_token_env_var = Some(env_var.into());
136 }
137 self
138 }
139
140 fn fields(&self) -> Vec<(String, String)> {
143 let mut out = Vec::new();
144 match &self.transport {
145 Transport::Stdio { command, args } => {
146 out.push(("command".into(), toml_string(command)));
147 if !args.is_empty() {
148 let items: Vec<String> = args.iter().map(|a| toml_string(a)).collect();
149 out.push(("args".into(), format!("[{}]", items.join(","))));
150 }
151 }
152 Transport::Http {
153 url,
154 bearer_token_env_var,
155 } => {
156 out.push(("url".into(), toml_string(url)));
157 if let Some(var) = bearer_token_env_var {
158 out.push(("bearer_token_env_var".into(), toml_string(var)));
159 }
160 }
161 }
162 if !self.env.is_empty() {
163 let pairs: Vec<String> = self
164 .env
165 .iter()
166 .map(|(k, v)| format!("{}={}", toml_key(k), toml_string(v)))
167 .collect();
168 out.push(("env".into(), format!("{{{}}}", pairs.join(","))));
169 }
170 out
171 }
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
176pub struct McpConfigBuilder {
177 servers: BTreeMap<String, McpServerConfig>,
178}
179
180impl McpConfigBuilder {
181 #[must_use]
183 pub fn new() -> Self {
184 Self::default()
185 }
186
187 #[must_use]
189 pub fn server(mut self, name: impl Into<String>, config: McpServerConfig) -> Self {
190 self.servers.insert(name.into(), config);
191 self
192 }
193
194 #[must_use]
196 pub fn stdio_server(self, name: impl Into<String>, command: impl Into<String>) -> Self {
197 self.server(name, McpServerConfig::stdio(command))
198 }
199
200 #[must_use]
202 pub fn http_server(self, name: impl Into<String>, url: impl Into<String>) -> Self {
203 self.server(name, McpServerConfig::http(url))
204 }
205
206 #[must_use]
212 pub fn config_overrides(&self) -> Vec<String> {
213 self.servers
214 .iter()
215 .flat_map(|(name, config)| {
216 config.fields().into_iter().map(move |(key, value)| {
217 format!("mcp_servers.{}.{key}={value}", toml_key(name))
218 })
219 })
220 .collect()
221 }
222
223 #[must_use]
228 pub fn to_toml(&self) -> String {
229 let mut out = String::new();
230 for (name, config) in &self.servers {
231 out.push_str(&format!("[mcp_servers.{}]\n", toml_key(name)));
232 for (key, value) in config.fields() {
233 out.push_str(&format!("{key} = {value}\n"));
234 }
235 out.push('\n');
236 }
237 out
238 }
239
240 pub fn write_profile(&self, codex_home: impl AsRef<Path>, profile: &str) -> Result<PathBuf> {
247 let path = codex_home.as_ref().join(format!("{profile}.config.toml"));
248 std::fs::write(&path, self.to_toml()).map_err(|e| Error::Io {
249 message: format!("failed to write {}: {e}", path.display()),
250 source: e,
251 working_dir: Some(codex_home.as_ref().to_path_buf()),
252 })?;
253 Ok(path)
254 }
255
256 #[must_use]
258 pub fn is_empty(&self) -> bool {
259 self.servers.is_empty()
260 }
261}
262
263fn toml_string(value: &str) -> String {
265 let mut out = String::with_capacity(value.len() + 2);
266 out.push('"');
267 for ch in value.chars() {
268 match ch {
269 '"' => out.push_str("\\\""),
270 '\\' => out.push_str("\\\\"),
271 '\n' => out.push_str("\\n"),
272 '\r' => out.push_str("\\r"),
273 '\t' => out.push_str("\\t"),
274 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
275 c => out.push(c),
276 }
277 }
278 out.push('"');
279 out
280}
281
282fn toml_key(key: &str) -> String {
287 let bare = !key.is_empty()
288 && key
289 .chars()
290 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
291 if bare {
292 key.to_string()
293 } else {
294 toml_string(key)
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
304 fn overrides_match_the_verified_forms() {
305 let mcp = McpConfigBuilder::new()
306 .server(
307 "files",
308 McpServerConfig::stdio("npx").arg("-y").arg("server"),
309 )
310 .server(
311 "docs",
312 McpServerConfig::http("https://example.com/mcp").bearer_token_env_var("TOKEN"),
313 );
314
315 assert_eq!(
316 mcp.config_overrides(),
317 vec![
318 r#"mcp_servers.docs.url="https://example.com/mcp""#,
319 r#"mcp_servers.docs.bearer_token_env_var="TOKEN""#,
320 r#"mcp_servers.files.command="npx""#,
321 r#"mcp_servers.files.args=["-y","server"]"#,
322 ]
323 );
324 }
325
326 #[test]
327 fn env_becomes_an_inline_table() {
328 let mcp = McpConfigBuilder::new().server(
329 "files",
330 McpServerConfig::stdio("run").env("B", "2").env("A", "1"),
331 );
332
333 assert_eq!(
334 mcp.config_overrides(),
335 vec![
336 r#"mcp_servers.files.command="run""#,
337 r#"mcp_servers.files.env={A="1",B="2"}"#,
338 ],
339 "entries are ordered, so the same set produces the same arguments"
340 );
341 }
342
343 #[test]
346 fn values_are_escaped() {
347 let mcp = McpConfigBuilder::new().server(
348 "s",
349 McpServerConfig::stdio(r#"say "hi""#)
350 .arg("back\\slash")
351 .arg("two\nlines"),
352 );
353
354 let overrides = mcp.config_overrides();
355 assert_eq!(overrides[0], r#"mcp_servers.s.command="say \"hi\"""#);
356 assert_eq!(
357 overrides[1],
358 r#"mcp_servers.s.args=["back\\slash","two\nlines"]"#
359 );
360 }
361
362 #[test]
365 fn a_name_needing_quotes_gets_them() {
366 let mcp = McpConfigBuilder::new().stdio_server("my.server", "run");
367 assert_eq!(
368 mcp.config_overrides(),
369 vec![r#"mcp_servers."my.server".command="run""#]
370 );
371 }
372
373 #[test]
374 fn args_and_bearer_token_apply_only_where_they_belong() {
375 let http =
377 McpConfigBuilder::new().server("h", McpServerConfig::http("https://x").arg("-y"));
378 assert_eq!(
379 http.config_overrides(),
380 vec![r#"mcp_servers.h.url="https://x""#]
381 );
382
383 let stdio = McpConfigBuilder::new()
384 .server("s", McpServerConfig::stdio("run").bearer_token_env_var("T"));
385 assert_eq!(
386 stdio.config_overrides(),
387 vec![r#"mcp_servers.s.command="run""#]
388 );
389 }
390
391 #[test]
392 fn to_toml_produces_a_profile_document() {
393 let mcp = McpConfigBuilder::new().server("files", McpServerConfig::stdio("npx").arg("-y"));
394
395 assert_eq!(
396 mcp.to_toml(),
397 "[mcp_servers.files]\ncommand = \"npx\"\nargs = [\"-y\"]\n\n"
398 );
399 }
400
401 #[test]
402 fn write_profile_lands_where_profile_would_look() {
403 let home = std::env::temp_dir().join(format!("codex-wrapper-mcp-{}", std::process::id()));
404 let _ = std::fs::remove_dir_all(&home);
405 std::fs::create_dir_all(&home).unwrap();
406
407 let path = McpConfigBuilder::new()
408 .stdio_server("files", "npx")
409 .write_profile(&home, "isolated")
410 .unwrap();
411
412 assert_eq!(path, home.join("isolated.config.toml"));
413 let written = std::fs::read_to_string(&path).unwrap();
414 assert!(written.contains("[mcp_servers.files]"), "{written}");
415
416 let _ = std::fs::remove_dir_all(&home);
417 }
418
419 #[test]
420 fn an_empty_builder_produces_nothing() {
421 let mcp = McpConfigBuilder::new();
422 assert!(mcp.is_empty());
423 assert!(mcp.config_overrides().is_empty());
424 assert_eq!(mcp.to_toml(), "");
425 }
426}