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//! ```
38//!
39//! # Example
40//!
41//! ```
42//! use codex_wrapper::{ExecCommand, McpConfigBuilder};
43//!
44//! let mcp = McpConfigBuilder::new()
45//!     .stdio_server("files", "npx")
46//!     .http_server("docs", "https://example.com/mcp");
47//!
48//! let mut cmd = ExecCommand::new("summarize the docs");
49//! for override_ in mcp.config_overrides() {
50//!     cmd = cmd.config(override_);
51//! }
52//! ```
53
54use std::collections::BTreeMap;
55use std::path::{Path, PathBuf};
56
57use crate::error::{Error, Result};
58
59/// How a server is reached.
60#[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/// One MCP server's configuration.
73///
74/// Mirrors what [`McpAddCommand`](crate::McpAddCommand) can register, so the
75/// two describe the same thing whether it is persisted or scoped to a run.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct McpServerConfig {
78    transport: Transport,
79    env: BTreeMap<String, String>,
80}
81
82impl McpServerConfig {
83    /// A server launched as a subprocess.
84    #[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    /// A server reached over HTTP.
96    #[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    /// Append a launch argument. Ignored for an HTTP server.
108    #[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    /// Set an environment variable for the subprocess.
117    #[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    /// Name the environment variable holding the bearer token.
124    ///
125    /// The token itself is never part of the configuration: only the name of
126    /// the variable to read it from, matching `mcp add --bearer-token-env-var`.
127    /// Ignored for a stdio server.
128    #[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    /// `key = value` pairs for this server, without the `mcp_servers.<name>.`
141    /// prefix.
142    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/// A set of MCP servers for one run.
175#[derive(Debug, Clone, Default, PartialEq, Eq)]
176pub struct McpConfigBuilder {
177    servers: BTreeMap<String, McpServerConfig>,
178}
179
180impl McpConfigBuilder {
181    /// An empty set.
182    #[must_use]
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    /// Add a server.
188    #[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    /// Add a subprocess server. Shorthand for [`McpServerConfig::stdio`].
195    #[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    /// Add an HTTP server. Shorthand for [`McpServerConfig::http`].
201    #[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    /// `key=value` strings for
207    /// [`ExecCommand::config`](crate::ExecCommand::config), or for the client
208    /// builder's `config` when the whole client should carry them.
209    ///
210    /// Ordered, so the same set produces the same arguments.
211    #[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    /// The same configuration as a TOML document.
224    ///
225    /// Suitable for a `$CODEX_HOME/<name>.config.toml` profile, which
226    /// `--profile` layers over the base config.
227    #[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    /// Write [`to_toml`](Self::to_toml) to `$CODEX_HOME/<profile>.config.toml`
241    /// and return the path.
242    ///
243    /// Reachable afterwards as `--profile <profile>`. This writes into the
244    /// user's codex home, so it is persistent: prefer
245    /// [`config_overrides`](Self::config_overrides) for a single run.
246    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    /// Whether any server has been added.
257    #[must_use]
258    pub fn is_empty(&self) -> bool {
259        self.servers.is_empty()
260    }
261}
262
263/// A TOML basic string, quoted and escaped.
264fn 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
282/// A TOML key, bare when it can be and quoted when it cannot.
283///
284/// Server names come from the caller, so a name with a dot would otherwise
285/// silently become a nested table rather than a server called `a.b`.
286fn 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    /// Exactly the forms accepted by `codex exec --strict-config` on 0.145.0.
303    #[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    /// A value carrying a quote or a backslash must not break out of the TOML
344    /// string and turn into a different override than intended.
345    #[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    /// A dotted name would otherwise become a nested table rather than a
363    /// server whose name contains a dot.
364    #[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        // An HTTP server ignores launch args; a stdio server ignores the token.
376        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}