Skip to main content

codex/mcp/
config.rs

1use std::{
2    collections::BTreeMap,
3    env, fs, io,
4    path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use thiserror::Error;
10use toml::{value::Table as TomlTable, Value as TomlValue};
11
12use super::{
13    AppRuntime, AppRuntimeLauncher, McpRuntimeServer, McpServerLauncher, StdioServerConfig,
14};
15
16/// Default config filename placed under CODEX_HOME.
17pub const DEFAULT_CONFIG_FILE: &str = "config.toml";
18const MCP_SERVERS_KEY: &str = "mcp_servers";
19const APP_RUNTIMES_KEY: &str = "app_runtimes";
20
21/// MCP server definition coupled with its name.
22#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
23pub struct McpServerEntry {
24    pub name: String,
25    pub definition: McpServerDefinition,
26}
27
28/// App runtime definition coupled with its name.
29#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
30pub struct AppRuntimeEntry {
31    pub name: String,
32    pub definition: AppRuntimeDefinition,
33}
34
35/// JSON-serializable MCP server configuration stored under `[mcp_servers]`.
36#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
37pub struct McpServerDefinition {
38    pub transport: McpTransport,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub description: Option<String>,
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub tags: Vec<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub tools: Option<McpToolConfig>,
45}
46
47/// Supported transport definitions for MCP servers.
48#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(tag = "transport", rename_all = "snake_case")]
50pub enum McpTransport {
51    Stdio(StdioServerDefinition),
52    StreamableHttp(StreamableHttpDefinition),
53}
54
55/// Stdio transport configuration for an MCP server.
56#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
57pub struct StdioServerDefinition {
58    pub command: String,
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub args: Vec<String>,
61    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
62    pub env: BTreeMap<String, String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub timeout_ms: Option<u64>,
65}
66
67/// HTTP transport configuration that supports streaming responses.
68#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
69pub struct StreamableHttpDefinition {
70    pub url: String,
71    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72    pub headers: BTreeMap<String, String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub bearer_env_var: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub connect_timeout_ms: Option<u64>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub request_timeout_ms: Option<u64>,
79}
80
81/// Tool allow/deny lists for a given MCP server.
82#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
83pub struct McpToolConfig {
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub enabled: Vec<String>,
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub disabled: Vec<String>,
88}
89
90/// Stored definition for launching an app-server runtime.
91#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
92pub struct AppRuntimeDefinition {
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub description: Option<String>,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub tags: Vec<String>,
97    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
98    pub env: BTreeMap<String, String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub code_home: Option<PathBuf>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub current_dir: Option<PathBuf>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub mirror_stdio: Option<bool>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub startup_timeout_ms: Option<u64>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub binary: Option<PathBuf>,
109    #[serde(default, skip_serializing_if = "Value::is_null")]
110    pub metadata: Value,
111}
112
113/// Input for adding or updating an app runtime entry.
114#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
115pub struct AddAppRuntimeRequest {
116    pub name: String,
117    pub definition: AppRuntimeDefinition,
118    #[serde(default)]
119    pub overwrite: bool,
120}
121
122/// Input for adding or updating an MCP server entry.
123#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
124pub struct AddServerRequest {
125    pub name: String,
126    pub definition: McpServerDefinition,
127    #[serde(default)]
128    pub overwrite: bool,
129    #[serde(default)]
130    pub env: BTreeMap<String, String>,
131    #[serde(default)]
132    pub bearer_token: Option<String>,
133}
134
135/// Result of logging into a server (auth token set in env var).
136#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
137pub struct McpLoginResult {
138    pub server: String,
139    pub env_var: Option<String>,
140}
141
142/// Result of clearing a stored auth token.
143#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
144pub struct McpLogoutResult {
145    pub server: String,
146    pub env_var: Option<String>,
147    pub cleared: bool,
148}
149
150/// Errors surfaced while managing MCP config entries.
151#[derive(Debug, Error)]
152pub enum McpConfigError {
153    #[error("failed to read {path}: {source}")]
154    Read {
155        path: PathBuf,
156        #[source]
157        source: io::Error,
158    },
159    #[error("failed to write {path}: {source}")]
160    Write {
161        path: PathBuf,
162        #[source]
163        source: io::Error,
164    },
165    #[error("failed to create directory {path}: {source}")]
166    CreateDir {
167        path: PathBuf,
168        #[source]
169        source: io::Error,
170    },
171    #[error("failed to parse {path}: {source}")]
172    Parse {
173        path: PathBuf,
174        #[source]
175        source: toml::de::Error,
176    },
177    #[error("config root at {path} must be a table")]
178    InvalidRoot { path: PathBuf },
179    #[error("`mcp_servers` must be a table in {path}")]
180    InvalidServers { path: PathBuf },
181    #[error("failed to decode mcp_servers: {source}")]
182    DecodeServers {
183        #[source]
184        source: toml::de::Error,
185    },
186    #[error("`app_runtimes` must be a table in {path}")]
187    InvalidAppRuntimes { path: PathBuf },
188    #[error("failed to decode app_runtimes: {source}")]
189    DecodeAppRuntimes {
190        #[source]
191        source: toml::de::Error,
192    },
193    #[error("failed to serialize config: {source}")]
194    Serialize {
195        #[source]
196        source: toml::ser::Error,
197    },
198    #[error("server `{0}` already exists")]
199    ServerAlreadyExists(String),
200    #[error("server `{0}` not found")]
201    ServerNotFound(String),
202    #[error("server name may not be empty")]
203    InvalidServerName,
204    #[error("app runtime `{0}` already exists")]
205    AppRuntimeAlreadyExists(String),
206    #[error("app runtime `{0}` not found")]
207    AppRuntimeNotFound(String),
208    #[error("app runtime name may not be empty")]
209    InvalidAppRuntimeName,
210    #[error("invalid env var name `{name}`")]
211    InvalidEnvVarName { name: String },
212    #[error("server `{server}` missing bearer_env_var for auth token")]
213    MissingBearerEnvVar { server: String },
214    #[error("server `{server}` transport does not support login/logout")]
215    UnsupportedAuthTransport { server: String },
216}
217
218/// Helper to load and mutate MCP + app runtime config stored under `[mcp_servers]` and
219/// `[app_runtimes]`.
220///
221/// Runtime, API, and pool helpers consume this manager in a read-only fashion so stored
222/// definitions, auth hints, and metadata are left untouched while preparing launch configs.
223pub struct McpConfigManager {
224    config_path: PathBuf,
225}
226
227impl McpConfigManager {
228    /// Create a manager that reads/writes the given config path.
229    pub fn new(config_path: impl Into<PathBuf>) -> Self {
230        Self {
231            config_path: config_path.into(),
232        }
233    }
234
235    /// Convenience constructor for a CODEX_HOME directory.
236    pub fn from_code_home(code_home: impl AsRef<Path>) -> Self {
237        Self::new(code_home.as_ref().join(DEFAULT_CONFIG_FILE))
238    }
239
240    /// Returns the underlying config path.
241    pub fn config_path(&self) -> &Path {
242        &self.config_path
243    }
244
245    /// Returns all configured MCP servers.
246    pub fn list_servers(&self) -> Result<Vec<McpServerEntry>, McpConfigError> {
247        let servers = self.read_servers()?;
248        Ok(servers
249            .into_iter()
250            .map(|(name, definition)| McpServerEntry { name, definition })
251            .collect())
252    }
253
254    /// Returns a single MCP server by name.
255    pub fn get_server(&self, name: &str) -> Result<McpServerEntry, McpConfigError> {
256        let servers = self.read_servers()?;
257        let Some(definition) = servers.get(name).cloned() else {
258            return Err(McpConfigError::ServerNotFound(name.to_string()));
259        };
260
261        Ok(McpServerEntry {
262            name: name.to_string(),
263            definition,
264        })
265    }
266
267    /// Returns all configured app runtimes.
268    pub fn list_app_runtimes(&self) -> Result<Vec<AppRuntimeEntry>, McpConfigError> {
269        let runtimes = self.read_app_runtimes()?;
270        Ok(runtimes
271            .into_iter()
272            .map(|(name, definition)| AppRuntimeEntry { name, definition })
273            .collect())
274    }
275
276    /// Returns a single app runtime by name.
277    pub fn get_app_runtime(&self, name: &str) -> Result<AppRuntimeEntry, McpConfigError> {
278        let runtimes = self.read_app_runtimes()?;
279        let Some(definition) = runtimes.get(name).cloned() else {
280            return Err(McpConfigError::AppRuntimeNotFound(name.to_string()));
281        };
282
283        Ok(AppRuntimeEntry {
284            name: name.to_string(),
285            definition,
286        })
287    }
288
289    /// Returns runtime-ready app configs with metadata preserved.
290    pub fn app_runtimes(&self) -> Result<Vec<AppRuntime>, McpConfigError> {
291        Ok(self
292            .list_app_runtimes()?
293            .into_iter()
294            .map(AppRuntime::from)
295            .collect())
296    }
297
298    /// Returns a runtime-ready app config for a single entry.
299    pub fn app_runtime(&self, name: &str) -> Result<AppRuntime, McpConfigError> {
300        self.get_app_runtime(name).map(AppRuntime::from)
301    }
302
303    /// Returns prepared launchers for all app runtimes.
304    pub fn app_runtime_launchers(
305        &self,
306        defaults: &StdioServerConfig,
307    ) -> Result<Vec<AppRuntimeLauncher>, McpConfigError> {
308        self.app_runtimes().map(|runtimes| {
309            runtimes
310                .into_iter()
311                .map(|runtime| runtime.into_launcher(defaults))
312                .collect()
313        })
314    }
315
316    /// Returns a prepared launcher for an app runtime by name.
317    pub fn app_runtime_launcher(
318        &self,
319        name: &str,
320        defaults: &StdioServerConfig,
321    ) -> Result<AppRuntimeLauncher, McpConfigError> {
322        self.app_runtime(name)
323            .map(|runtime| runtime.into_launcher(defaults))
324    }
325
326    /// Returns runtime-ready configs for all servers, resolving bearer tokens from the environment.
327    pub fn runtime_servers(&self) -> Result<Vec<McpRuntimeServer>, McpConfigError> {
328        Ok(self
329            .list_servers()?
330            .into_iter()
331            .map(McpRuntimeServer::from)
332            .collect())
333    }
334
335    /// Returns a runtime-ready config for a single server by name.
336    pub fn runtime_server(&self, name: &str) -> Result<McpRuntimeServer, McpConfigError> {
337        self.get_server(name).map(McpRuntimeServer::from)
338    }
339
340    /// Returns prepared launchers/connectors for all runtime servers.
341    pub fn runtime_launchers(
342        &self,
343        defaults: &StdioServerConfig,
344    ) -> Result<Vec<McpServerLauncher>, McpConfigError> {
345        self.runtime_servers().map(|servers| {
346            servers
347                .into_iter()
348                .map(|server| server.into_launcher(defaults))
349                .collect()
350        })
351    }
352
353    /// Returns a prepared launcher/connector for a single runtime server by name.
354    pub fn runtime_launcher(
355        &self,
356        name: &str,
357        defaults: &StdioServerConfig,
358    ) -> Result<McpServerLauncher, McpConfigError> {
359        self.runtime_server(name)
360            .map(|server| server.into_launcher(defaults))
361    }
362
363    /// Adds or updates an app runtime definition.
364    pub fn add_app_runtime(
365        &self,
366        request: AddAppRuntimeRequest,
367    ) -> Result<AppRuntimeEntry, McpConfigError> {
368        let AddAppRuntimeRequest {
369            name,
370            definition,
371            overwrite,
372        } = request;
373
374        if name.trim().is_empty() {
375            return Err(McpConfigError::InvalidAppRuntimeName);
376        }
377
378        let (table, mut runtimes) = self.read_table_and_app_runtimes()?;
379        if !overwrite && runtimes.contains_key(&name) {
380            return Err(McpConfigError::AppRuntimeAlreadyExists(name));
381        }
382
383        runtimes.insert(name.clone(), definition.clone());
384        self.persist_app_runtimes(table, &runtimes)?;
385
386        Ok(AppRuntimeEntry { name, definition })
387    }
388
389    /// Adds or updates a server definition and injects any provided env vars.
390    pub fn add_server(
391        &self,
392        mut request: AddServerRequest,
393    ) -> Result<McpServerEntry, McpConfigError> {
394        if request.name.trim().is_empty() {
395            return Err(McpConfigError::InvalidServerName);
396        }
397
398        let mut env_injections = request.env.clone();
399        if let Some(token) = request.bearer_token.take() {
400            let var = Self::bearer_env_var(&request.name, &request.definition)?;
401            env_injections.entry(var).or_insert(token);
402        }
403
404        if let McpTransport::Stdio(transport) = &mut request.definition.transport {
405            for (key, value) in &env_injections {
406                transport.env.entry(key.clone()).or_insert(value.clone());
407            }
408        }
409
410        self.set_env_vars(&env_injections)?;
411
412        let (table, mut servers) = self.read_table_and_servers()?;
413        if !request.overwrite && servers.contains_key(&request.name) {
414            return Err(McpConfigError::ServerAlreadyExists(request.name));
415        }
416
417        servers.insert(request.name.clone(), request.definition.clone());
418        self.persist_servers(table, &servers)?;
419
420        Ok(McpServerEntry {
421            name: request.name,
422            definition: request.definition,
423        })
424    }
425
426    /// Removes a server definition. Returns the removed entry if it existed.
427    pub fn remove_server(&self, name: &str) -> Result<Option<McpServerEntry>, McpConfigError> {
428        let (table, mut servers) = self.read_table_and_servers()?;
429        let removed = servers.remove(name).map(|definition| McpServerEntry {
430            name: name.to_string(),
431            definition,
432        });
433
434        if removed.is_some() {
435            self.persist_servers(table, &servers)?;
436        }
437
438        Ok(removed)
439    }
440
441    /// Writes the provided token into the server's bearer env var.
442    pub fn login(
443        &self,
444        name: &str,
445        token: impl AsRef<str>,
446    ) -> Result<McpLoginResult, McpConfigError> {
447        let servers = self.read_servers()?;
448        let definition = servers
449            .get(name)
450            .ok_or_else(|| McpConfigError::ServerNotFound(name.to_string()))?;
451        let env_var = Self::bearer_env_var(name, definition)?;
452        self.validate_env_key(&env_var)?;
453        env::set_var(&env_var, token.as_ref());
454        Ok(McpLoginResult {
455            server: name.to_string(),
456            env_var: Some(env_var),
457        })
458    }
459
460    /// Clears the bearer env var used for the given server.
461    pub fn logout(&self, name: &str) -> Result<McpLogoutResult, McpConfigError> {
462        let servers = self.read_servers()?;
463        let definition = servers
464            .get(name)
465            .ok_or_else(|| McpConfigError::ServerNotFound(name.to_string()))?;
466        let env_var = Self::bearer_env_var(name, definition)?;
467        let cleared = env::var(&env_var).is_ok();
468        env::remove_var(&env_var);
469        Ok(McpLogoutResult {
470            server: name.to_string(),
471            env_var: Some(env_var),
472            cleared,
473        })
474    }
475
476    fn bearer_env_var(
477        name: &str,
478        definition: &McpServerDefinition,
479    ) -> Result<String, McpConfigError> {
480        match &definition.transport {
481            McpTransport::StreamableHttp(http) => {
482                http.bearer_env_var
483                    .clone()
484                    .ok_or_else(|| McpConfigError::MissingBearerEnvVar {
485                        server: name.to_string(),
486                    })
487            }
488            McpTransport::Stdio(_) => Err(McpConfigError::UnsupportedAuthTransport {
489                server: name.to_string(),
490            }),
491        }
492    }
493
494    fn read_servers(&self) -> Result<BTreeMap<String, McpServerDefinition>, McpConfigError> {
495        let table = self.load_table()?;
496        self.parse_servers(table.get(MCP_SERVERS_KEY))
497    }
498
499    fn read_table_and_servers(
500        &self,
501    ) -> Result<(TomlTable, BTreeMap<String, McpServerDefinition>), McpConfigError> {
502        let table = self.load_table()?;
503        let servers = self.parse_servers(table.get(MCP_SERVERS_KEY))?;
504        Ok((table, servers))
505    }
506
507    fn parse_servers(
508        &self,
509        value: Option<&TomlValue>,
510    ) -> Result<BTreeMap<String, McpServerDefinition>, McpConfigError> {
511        let Some(value) = value else {
512            return Ok(BTreeMap::new());
513        };
514
515        let table = value
516            .as_table()
517            .ok_or_else(|| McpConfigError::InvalidServers {
518                path: self.config_path.clone(),
519            })?;
520        let cloned = TomlValue::Table(table.clone());
521        cloned
522            .try_into()
523            .map_err(|source| McpConfigError::DecodeServers { source })
524    }
525
526    fn persist_servers(
527        &self,
528        mut table: TomlTable,
529        servers: &BTreeMap<String, McpServerDefinition>,
530    ) -> Result<(), McpConfigError> {
531        if servers.is_empty() {
532            table.remove(MCP_SERVERS_KEY);
533        } else {
534            let value = TomlValue::try_from(servers.clone())
535                .map_err(|source| McpConfigError::Serialize { source })?;
536            table.insert(MCP_SERVERS_KEY.to_string(), value);
537        }
538
539        self.write_table(table)
540    }
541
542    fn read_app_runtimes(&self) -> Result<BTreeMap<String, AppRuntimeDefinition>, McpConfigError> {
543        let table = self.load_table()?;
544        self.parse_app_runtimes(table.get(APP_RUNTIMES_KEY))
545    }
546
547    fn read_table_and_app_runtimes(
548        &self,
549    ) -> Result<(TomlTable, BTreeMap<String, AppRuntimeDefinition>), McpConfigError> {
550        let table = self.load_table()?;
551        let runtimes = self.parse_app_runtimes(table.get(APP_RUNTIMES_KEY))?;
552        Ok((table, runtimes))
553    }
554
555    fn parse_app_runtimes(
556        &self,
557        value: Option<&TomlValue>,
558    ) -> Result<BTreeMap<String, AppRuntimeDefinition>, McpConfigError> {
559        let Some(value) = value else {
560            return Ok(BTreeMap::new());
561        };
562
563        let table = value
564            .as_table()
565            .ok_or_else(|| McpConfigError::InvalidAppRuntimes {
566                path: self.config_path.clone(),
567            })?;
568        let cloned = TomlValue::Table(table.clone());
569        cloned
570            .try_into()
571            .map_err(|source| McpConfigError::DecodeAppRuntimes { source })
572    }
573
574    fn persist_app_runtimes(
575        &self,
576        mut table: TomlTable,
577        runtimes: &BTreeMap<String, AppRuntimeDefinition>,
578    ) -> Result<(), McpConfigError> {
579        if runtimes.is_empty() {
580            table.remove(APP_RUNTIMES_KEY);
581        } else {
582            let value = TomlValue::try_from(runtimes.clone())
583                .map_err(|source| McpConfigError::Serialize { source })?;
584            table.insert(APP_RUNTIMES_KEY.to_string(), value);
585        }
586
587        self.write_table(table)
588    }
589
590    fn load_table(&self) -> Result<TomlTable, McpConfigError> {
591        if !self.config_path.exists() {
592            return Ok(TomlTable::new());
593        }
594
595        let contents =
596            fs::read_to_string(&self.config_path).map_err(|source| McpConfigError::Read {
597                path: self.config_path.clone(),
598                source,
599            })?;
600
601        if contents.trim().is_empty() {
602            return Ok(TomlTable::new());
603        }
604
605        let value: TomlValue = contents.parse().map_err(|source| McpConfigError::Parse {
606            path: self.config_path.clone(),
607            source,
608        })?;
609
610        value
611            .as_table()
612            .cloned()
613            .ok_or_else(|| McpConfigError::InvalidRoot {
614                path: self.config_path.clone(),
615            })
616    }
617
618    fn write_table(&self, table: TomlTable) -> Result<(), McpConfigError> {
619        if let Some(parent) = self.config_path.parent() {
620            fs::create_dir_all(parent).map_err(|source| McpConfigError::CreateDir {
621                path: parent.to_path_buf(),
622                source,
623            })?;
624        }
625
626        let serialized = toml::to_string_pretty(&TomlValue::Table(table))
627            .map_err(|source| McpConfigError::Serialize { source })?;
628
629        fs::write(&self.config_path, serialized).map_err(|source| McpConfigError::Write {
630            path: self.config_path.clone(),
631            source,
632        })
633    }
634
635    fn set_env_vars(&self, vars: &BTreeMap<String, String>) -> Result<(), McpConfigError> {
636        for (key, value) in vars {
637            self.validate_env_key(key)?;
638            env::set_var(key, value);
639        }
640        Ok(())
641    }
642
643    fn validate_env_key(&self, key: &str) -> Result<(), McpConfigError> {
644        let invalid = key.is_empty() || key.contains('=') || key.contains('\0');
645        if invalid {
646            return Err(McpConfigError::InvalidEnvVarName {
647                name: key.to_string(),
648            });
649        }
650        Ok(())
651    }
652}