1use std::collections::BTreeMap;
57use std::path::{Path, PathBuf};
58
59use crate::error::{Error, Result};
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63enum Transport {
64 Stdio {
65 command: String,
66 args: Vec<String>,
67 },
68 Http {
69 url: String,
70 bearer_token_env_var: Option<String>,
71 env_http_headers: BTreeMap<String, String>,
72 },
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct McpServerConfig {
81 transport: Transport,
82 env: BTreeMap<String, String>,
83 required: bool,
84}
85
86impl McpServerConfig {
87 #[must_use]
89 pub fn stdio(command: impl Into<String>) -> Self {
90 Self {
91 transport: Transport::Stdio {
92 command: command.into(),
93 args: Vec::new(),
94 },
95 env: BTreeMap::new(),
96 required: false,
97 }
98 }
99
100 #[must_use]
102 pub fn http(url: impl Into<String>) -> Self {
103 Self {
104 transport: Transport::Http {
105 url: url.into(),
106 bearer_token_env_var: None,
107 env_http_headers: BTreeMap::new(),
108 },
109 env: BTreeMap::new(),
110 required: false,
111 }
112 }
113
114 #[must_use]
116 pub fn arg(mut self, value: impl Into<String>) -> Self {
117 if let Transport::Stdio { args, .. } = &mut self.transport {
118 args.push(value.into());
119 }
120 self
121 }
122
123 #[must_use]
125 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
126 self.env.insert(key.into(), value.into());
127 self
128 }
129
130 #[must_use]
136 pub fn bearer_token_env_var(mut self, env_var: impl Into<String>) -> Self {
137 if let Transport::Http {
138 bearer_token_env_var,
139 ..
140 } = &mut self.transport
141 {
142 *bearer_token_env_var = Some(env_var.into());
143 }
144 self
145 }
146
147 #[must_use]
154 pub fn env_http_header(
155 mut self,
156 header: impl Into<String>,
157 env_var: impl Into<String>,
158 ) -> Self {
159 if let Transport::Http {
160 env_http_headers, ..
161 } = &mut self.transport
162 {
163 env_http_headers.insert(header.into(), env_var.into());
164 }
165 self
166 }
167
168 #[must_use]
173 pub fn required(mut self) -> Self {
174 self.required = true;
175 self
176 }
177
178 fn fields(&self) -> Vec<(String, String)> {
181 let mut out = Vec::new();
182 match &self.transport {
183 Transport::Stdio { command, args } => {
184 out.push(("command".into(), toml_string(command)));
185 if !args.is_empty() {
186 let items: Vec<String> = args.iter().map(|a| toml_string(a)).collect();
187 out.push(("args".into(), format!("[{}]", items.join(","))));
188 }
189 }
190 Transport::Http {
191 url,
192 bearer_token_env_var,
193 env_http_headers,
194 } => {
195 out.push(("url".into(), toml_string(url)));
196 if let Some(var) = bearer_token_env_var {
197 out.push(("bearer_token_env_var".into(), toml_string(var)));
198 }
199 if !env_http_headers.is_empty() {
200 let pairs: Vec<String> = env_http_headers
201 .iter()
202 .map(|(header, var)| format!("{}={}", toml_key(header), toml_string(var)))
203 .collect();
204 out.push((
205 "env_http_headers".into(),
206 format!("{{{}}}", pairs.join(",")),
207 ));
208 }
209 }
210 }
211 if !self.env.is_empty() {
212 let pairs: Vec<String> = self
213 .env
214 .iter()
215 .map(|(k, v)| format!("{}={}", toml_key(k), toml_string(v)))
216 .collect();
217 out.push(("env".into(), format!("{{{}}}", pairs.join(","))));
218 }
219 if self.required {
220 out.push(("required".into(), "true".into()));
221 }
222 out
223 }
224}
225
226#[derive(Debug, Clone, Default, PartialEq, Eq)]
228pub struct McpConfigBuilder {
229 servers: BTreeMap<String, McpServerConfig>,
230}
231
232impl McpConfigBuilder {
233 #[must_use]
235 pub fn new() -> Self {
236 Self::default()
237 }
238
239 #[must_use]
241 pub fn server(mut self, name: impl Into<String>, config: McpServerConfig) -> Self {
242 self.servers.insert(name.into(), config);
243 self
244 }
245
246 #[must_use]
248 pub fn stdio_server(self, name: impl Into<String>, command: impl Into<String>) -> Self {
249 self.server(name, McpServerConfig::stdio(command))
250 }
251
252 #[must_use]
254 pub fn http_server(self, name: impl Into<String>, url: impl Into<String>) -> Self {
255 self.server(name, McpServerConfig::http(url))
256 }
257
258 #[must_use]
264 pub fn config_overrides(&self) -> Vec<String> {
265 self.servers
266 .iter()
267 .flat_map(|(name, config)| {
268 config.fields().into_iter().map(move |(key, value)| {
269 format!("mcp_servers.{}.{key}={value}", toml_key(name))
270 })
271 })
272 .collect()
273 }
274
275 #[must_use]
280 pub fn to_toml(&self) -> String {
281 let mut out = String::new();
282 for (name, config) in &self.servers {
283 out.push_str(&format!("[mcp_servers.{}]\n", toml_key(name)));
284 for (key, value) in config.fields() {
285 out.push_str(&format!("{key} = {value}\n"));
286 }
287 out.push('\n');
288 }
289 out
290 }
291
292 pub fn write_profile(&self, codex_home: impl AsRef<Path>, profile: &str) -> Result<PathBuf> {
299 let path = codex_home.as_ref().join(format!("{profile}.config.toml"));
300 std::fs::write(&path, self.to_toml()).map_err(|e| Error::Io {
301 message: format!("failed to write {}: {e}", path.display()),
302 source: e,
303 working_dir: Some(codex_home.as_ref().to_path_buf()),
304 })?;
305 Ok(path)
306 }
307
308 #[must_use]
310 pub fn is_empty(&self) -> bool {
311 self.servers.is_empty()
312 }
313}
314
315fn toml_string(value: &str) -> String {
317 let mut out = String::with_capacity(value.len() + 2);
318 out.push('"');
319 for ch in value.chars() {
320 match ch {
321 '"' => out.push_str("\\\""),
322 '\\' => out.push_str("\\\\"),
323 '\n' => out.push_str("\\n"),
324 '\r' => out.push_str("\\r"),
325 '\t' => out.push_str("\\t"),
326 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
327 c => out.push(c),
328 }
329 }
330 out.push('"');
331 out
332}
333
334fn toml_key(key: &str) -> String {
339 let bare = !key.is_empty()
340 && key
341 .chars()
342 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
343 if bare {
344 key.to_string()
345 } else {
346 toml_string(key)
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
356 fn overrides_match_the_verified_forms() {
357 let mcp = McpConfigBuilder::new()
358 .server(
359 "files",
360 McpServerConfig::stdio("npx").arg("-y").arg("server"),
361 )
362 .server(
363 "docs",
364 McpServerConfig::http("https://example.com/mcp")
365 .bearer_token_env_var("TOKEN")
366 .env_http_header("X-Identity", "IDENTITY_TOKEN")
367 .required(),
368 );
369
370 assert_eq!(
371 mcp.config_overrides(),
372 vec![
373 r#"mcp_servers.docs.url="https://example.com/mcp""#,
374 r#"mcp_servers.docs.bearer_token_env_var="TOKEN""#,
375 r#"mcp_servers.docs.env_http_headers={X-Identity="IDENTITY_TOKEN"}"#,
376 "mcp_servers.docs.required=true",
377 r#"mcp_servers.files.command="npx""#,
378 r#"mcp_servers.files.args=["-y","server"]"#,
379 ]
380 );
381 }
382
383 #[test]
384 fn env_becomes_an_inline_table() {
385 let mcp = McpConfigBuilder::new().server(
386 "files",
387 McpServerConfig::stdio("run").env("B", "2").env("A", "1"),
388 );
389
390 assert_eq!(
391 mcp.config_overrides(),
392 vec![
393 r#"mcp_servers.files.command="run""#,
394 r#"mcp_servers.files.env={A="1",B="2"}"#,
395 ],
396 "entries are ordered, so the same set produces the same arguments"
397 );
398 }
399
400 #[test]
403 fn values_are_escaped() {
404 let mcp = McpConfigBuilder::new().server(
405 "s",
406 McpServerConfig::stdio(r#"say "hi""#)
407 .arg("back\\slash")
408 .arg("two\nlines"),
409 );
410
411 let overrides = mcp.config_overrides();
412 assert_eq!(overrides[0], r#"mcp_servers.s.command="say \"hi\"""#);
413 assert_eq!(
414 overrides[1],
415 r#"mcp_servers.s.args=["back\\slash","two\nlines"]"#
416 );
417 }
418
419 #[test]
422 fn a_name_needing_quotes_gets_them() {
423 let mcp = McpConfigBuilder::new().stdio_server("my.server", "run");
424 assert_eq!(
425 mcp.config_overrides(),
426 vec![r#"mcp_servers."my.server".command="run""#]
427 );
428 }
429
430 #[test]
431 fn args_and_bearer_token_apply_only_where_they_belong() {
432 let http =
434 McpConfigBuilder::new().server("h", McpServerConfig::http("https://x").arg("-y"));
435 assert_eq!(
436 http.config_overrides(),
437 vec![r#"mcp_servers.h.url="https://x""#]
438 );
439
440 let stdio = McpConfigBuilder::new()
441 .server("s", McpServerConfig::stdio("run").bearer_token_env_var("T"));
442 assert_eq!(
443 stdio.config_overrides(),
444 vec![r#"mcp_servers.s.command="run""#]
445 );
446
447 let stdio = McpConfigBuilder::new().server(
448 "s",
449 McpServerConfig::stdio("run").env_http_header("X-Identity", "TOKEN"),
450 );
451 assert_eq!(
452 stdio.config_overrides(),
453 vec![r#"mcp_servers.s.command="run""#]
454 );
455 }
456
457 #[test]
458 fn env_backed_http_headers_are_ordered_and_escape_header_names() {
459 let mcp = McpConfigBuilder::new().server(
460 "api",
461 McpServerConfig::http("https://example.com/mcp")
462 .env_http_header("x.second", "SECOND_TOKEN")
463 .env_http_header("x-first", "FIRST_TOKEN"),
464 );
465
466 assert_eq!(
467 mcp.config_overrides(),
468 vec![
469 r#"mcp_servers.api.url="https://example.com/mcp""#,
470 r#"mcp_servers.api.env_http_headers={x-first="FIRST_TOKEN","x.second"="SECOND_TOKEN"}"#,
471 ]
472 );
473 }
474
475 #[test]
476 fn to_toml_produces_a_profile_document() {
477 let mcp = McpConfigBuilder::new().server("files", McpServerConfig::stdio("npx").arg("-y"));
478
479 assert_eq!(
480 mcp.to_toml(),
481 "[mcp_servers.files]\ncommand = \"npx\"\nargs = [\"-y\"]\n\n"
482 );
483 }
484
485 #[test]
486 fn write_profile_lands_where_profile_would_look() {
487 let home = std::env::temp_dir().join(format!("codex-wrapper-mcp-{}", std::process::id()));
488 let _ = std::fs::remove_dir_all(&home);
489 std::fs::create_dir_all(&home).unwrap();
490
491 let path = McpConfigBuilder::new()
492 .stdio_server("files", "npx")
493 .write_profile(&home, "isolated")
494 .unwrap();
495
496 assert_eq!(path, home.join("isolated.config.toml"));
497 let written = std::fs::read_to_string(&path).unwrap();
498 assert!(written.contains("[mcp_servers.files]"), "{written}");
499
500 let _ = std::fs::remove_dir_all(&home);
501 }
502
503 #[test]
504 fn an_empty_builder_produces_nothing() {
505 let mcp = McpConfigBuilder::new();
506 assert!(mcp.is_empty());
507 assert!(mcp.config_overrides().is_empty());
508 assert_eq!(mcp.to_toml(), "");
509 }
510}