Skip to main content

codex_wrapper/
mcp_config.rs

1//! Build MCP server configuration for a single run, without touching the
2//! user's persistent config.
3//!
4//! [`McpAddCommand`](crate::McpAddCommand) and friends mutate
5//! `$CODEX_HOME/config.toml`. That is the wrong tool for a host running many
6//! isolated invocations: a cancelled run leaves residue, and two overlapping
7//! runs race each other.
8//!
9//! # Why overrides rather than a config file
10//!
11//! `claude-wrapper`'s equivalent writes a JSON file and passes it to
12//! `--mcp-config`. Codex has no such flag. Checked against 0.145.0: the only
13//! config-bearing options on `codex exec` are `-c/--config`, `--profile`,
14//! which layers `$CODEX_HOME/<name>.config.toml`, and `--ignore-user-config`.
15//! Nothing consumes a standalone server-config file.
16//!
17//! So the per-run mechanism here is `-c` overrides, which suits the purpose
18//! better than a file would: nothing is written, nothing is left behind when a
19//! run is cancelled, and concurrent runs cannot collide.
20//!
21//! For the cases that do want a file, [`McpConfigBuilder::to_toml`] and
22//! [`McpConfigBuilder::write_profile`] produce a profile that `--profile`
23//! layers.
24//!
25//! # Verified forms
26//!
27//! Each of these was accepted by `codex exec --strict-config`, which rejects
28//! a malformed server outright (a table with no transport fails with
29//! `invalid transport`):
30//!
31//! ```text
32//! mcp_servers.<name>.command="npx"
33//! mcp_servers.<name>.args=["-y","server"]
34//! mcp_servers.<name>.env={API_KEY="x"}
35//! mcp_servers.<name>.url="https://example.com/mcp"
36//! mcp_servers.<name>.bearer_token_env_var="TOKEN"
37//! mcp_servers.<name>.env_http_headers={X-Identity="IDENTITY_TOKEN"}
38//! mcp_servers.<name>.required=true
39//! ```
40//!
41//! # Example
42//!
43//! ```
44//! use codex_wrapper::{ExecCommand, McpConfigBuilder};
45//!
46//! let mcp = McpConfigBuilder::new()
47//!     .stdio_server("files", "npx")
48//!     .http_server("docs", "https://example.com/mcp");
49//!
50//! let mut cmd = ExecCommand::new("summarize the docs");
51//! for override_ in mcp.config_overrides() {
52//!     cmd = cmd.config(override_);
53//! }
54//! ```
55
56use std::collections::BTreeMap;
57use std::path::{Path, PathBuf};
58
59use crate::error::{Error, Result};
60
61/// How a server is reached.
62#[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/// One MCP server's configuration.
76///
77/// Mirrors what [`McpAddCommand`](crate::McpAddCommand) can register, so the
78/// two describe the same thing whether it is persisted or scoped to a run.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct McpServerConfig {
81    transport: Transport,
82    env: BTreeMap<String, String>,
83    required: bool,
84}
85
86impl McpServerConfig {
87    /// A server launched as a subprocess.
88    #[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    /// A server reached over HTTP.
101    #[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    /// Append a launch argument. Ignored for an HTTP server.
115    #[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    /// Set an environment variable for the subprocess.
124    #[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    /// Name the environment variable holding the bearer token.
131    ///
132    /// The token itself is never part of the configuration: only the name of
133    /// the variable to read it from, matching `mcp add --bearer-token-env-var`.
134    /// Ignored for a stdio server.
135    #[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    /// Source an HTTP request header from an environment variable.
148    ///
149    /// Both arguments are names: `header` is sent on each request and
150    /// `env_var` is read by Codex for its value. The secret itself therefore
151    /// stays out of argv and persistent configuration. Ignored for a stdio
152    /// server.
153    #[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    /// Require this server to initialize successfully.
169    ///
170    /// Codex otherwise treats an unavailable MCP server as a warning and may
171    /// continue without capabilities the caller expected to be present.
172    #[must_use]
173    pub fn required(mut self) -> Self {
174        self.required = true;
175        self
176    }
177
178    /// `key = value` pairs for this server, without the `mcp_servers.<name>.`
179    /// prefix.
180    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/// A set of MCP servers for one run.
227#[derive(Debug, Clone, Default, PartialEq, Eq)]
228pub struct McpConfigBuilder {
229    servers: BTreeMap<String, McpServerConfig>,
230}
231
232impl McpConfigBuilder {
233    /// An empty set.
234    #[must_use]
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    /// Add a server.
240    #[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    /// Add a subprocess server. Shorthand for [`McpServerConfig::stdio`].
247    #[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    /// Add an HTTP server. Shorthand for [`McpServerConfig::http`].
253    #[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    /// `key=value` strings for
259    /// [`ExecCommand::config`](crate::ExecCommand::config), or for the client
260    /// builder's `config` when the whole client should carry them.
261    ///
262    /// Ordered, so the same set produces the same arguments.
263    #[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    /// The same configuration as a TOML document.
276    ///
277    /// Suitable for a `$CODEX_HOME/<name>.config.toml` profile, which
278    /// `--profile` layers over the base config.
279    #[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    /// Write [`to_toml`](Self::to_toml) to `$CODEX_HOME/<profile>.config.toml`
293    /// and return the path.
294    ///
295    /// Reachable afterwards as `--profile <profile>`. This writes into the
296    /// user's codex home, so it is persistent: prefer
297    /// [`config_overrides`](Self::config_overrides) for a single run.
298    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    /// Whether any server has been added.
309    #[must_use]
310    pub fn is_empty(&self) -> bool {
311        self.servers.is_empty()
312    }
313}
314
315/// A TOML basic string, quoted and escaped.
316fn 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
334/// A TOML key, bare when it can be and quoted when it cannot.
335///
336/// Server names come from the caller, so a name with a dot would otherwise
337/// silently become a nested table rather than a server called `a.b`.
338fn 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    /// Exactly the forms accepted by `codex exec --strict-config` on 0.145.0.
355    #[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    /// A value carrying a quote or a backslash must not break out of the TOML
401    /// string and turn into a different override than intended.
402    #[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    /// A dotted name would otherwise become a nested table rather than a
420    /// server whose name contains a dot.
421    #[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        // An HTTP server ignores launch args; a stdio server ignores the token.
433        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}