Skip to main content

agent_config/agents/codex/
mcp.rs

1//! Codex MCP surface. MCP servers live in `<codex-home>/config.toml` (Global)
2//! or `<root>/.codex/config.toml` (Local) as `[mcp_servers.<name>]` tables.
3//! Uses `toml_edit` to preserve user comments and ordering.
4
5use std::path::PathBuf;
6
7use toml_edit::{value, Array, InlineTable, Table};
8
9use crate::agents::planning as agent_planning;
10use crate::error::AgentConfigError;
11use crate::integration::{InstallReport, McpSurface, UninstallReport};
12use crate::paths;
13use crate::plan::{
14    has_refusal, InstallPlan, PlanTarget, PlannedChange, RefusalReason, UninstallPlan,
15};
16use crate::scope::{Scope, ScopeKind};
17use crate::spec::{HookSpec, McpSpec, McpTransport};
18use crate::status::StatusReport;
19use crate::util::{file_lock, ownership, planning, safe_fs, toml_patch};
20
21use super::CodexAgent;
22
23impl CodexAgent {
24    /// `<codex-home>/config.toml` (Global) or `<root>/.codex/config.toml`
25    /// (Local). MCP servers live here as `[mcp_servers.<name>]` tables.
26    pub(super) fn config_toml_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
27        Ok(match scope {
28            Scope::Global => paths::codex_home()?.join("config.toml"),
29            Scope::Local(p) => p.join(".codex").join("config.toml"),
30        })
31    }
32}
33
34impl McpSurface for CodexAgent {
35    fn id(&self) -> &'static str {
36        "codex"
37    }
38
39    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
40        &[ScopeKind::Global, ScopeKind::Local]
41    }
42
43    fn mcp_status(
44        &self,
45        scope: &Scope,
46        name: &str,
47        expected_owner: &str,
48    ) -> Result<StatusReport, AgentConfigError> {
49        McpSpec::validate_name(name)?;
50        let cfg = Self::config_toml_path(scope)?;
51        let ledger = ownership::mcp_ledger_for(&cfg);
52        let presence = toml_patch::config_presence(&cfg, &["mcp_servers"], name)?;
53        let recorded = ownership::owner_of(&ledger, name)?;
54        Ok(StatusReport::for_mcp(
55            name,
56            cfg,
57            ledger,
58            presence,
59            expected_owner,
60            recorded,
61        ))
62    }
63
64    fn plan_install_mcp(
65        &self,
66        scope: &Scope,
67        spec: &McpSpec,
68    ) -> Result<InstallPlan, AgentConfigError> {
69        spec.validate()?;
70        let target = PlanTarget::Mcp {
71            integration_id: McpSurface::id(self),
72            scope: scope.clone(),
73            name: spec.name.clone(),
74            owner: spec.owner_tag.clone(),
75        };
76        let cfg = Self::config_toml_path(scope)?;
77        if let Some(plan) = agent_planning::mcp_local_inline_secret_refusal(
78            target.clone(),
79            scope,
80            spec,
81            Some(cfg.clone()),
82        ) {
83            return Ok(plan);
84        }
85        let ledger = ownership::mcp_ledger_for(&cfg);
86        let mut changes = Vec::new();
87        let mut doc = match toml_patch::read_or_empty(&cfg) {
88            Ok(doc) => doc,
89            Err(AgentConfigError::TomlInvalid { .. }) => {
90                changes.push(PlannedChange::Refuse {
91                    path: Some(cfg),
92                    reason: RefusalReason::InvalidConfig,
93                });
94                return Ok(InstallPlan::from_changes(target, changes));
95            }
96            Err(e) => return Err(e),
97        };
98        let in_config = toml_patch::contains_named_table(&doc, &["mcp_servers"], &spec.name);
99        let prior_owner = ownership::owner_of(&ledger, &spec.name)?;
100        let adopting = spec.adopt_unowned && in_config && prior_owner.is_none();
101        match (prior_owner.as_deref(), in_config) {
102            (Some(owner), _) if owner != spec.owner_tag => {
103                changes.push(PlannedChange::Refuse {
104                    path: Some(ledger),
105                    reason: RefusalReason::OwnerMismatch,
106                });
107                return Ok(InstallPlan::from_changes(target, changes));
108            }
109            (None, true) if !spec.adopt_unowned => {
110                changes.push(PlannedChange::Refuse {
111                    path: Some(cfg),
112                    reason: RefusalReason::UserInstalledEntry,
113                });
114                return Ok(InstallPlan::from_changes(target, changes));
115            }
116            _ => {}
117        }
118
119        let table = build_mcp_table(spec);
120        let changed =
121            toml_patch::upsert_named_table(&mut doc, &["mcp_servers"], &spec.name, table)?;
122        let owner_changed = prior_owner.as_deref() != Some(spec.owner_tag.as_str());
123        if changed {
124            let bytes = toml_patch::to_string(&doc);
125            planning::plan_write_file(&mut changes, &cfg, &bytes, true)?;
126        }
127        if !has_refusal(&changes) && (changed || owner_changed || adopting) {
128            planning::plan_write_ledger(&mut changes, &ledger, &spec.name, &spec.owner_tag);
129        }
130        if changes.is_empty() {
131            changes.push(PlannedChange::NoOp {
132                path: cfg.clone(),
133                reason: "MCP server is already up to date".into(),
134            });
135        }
136        Ok(agent_planning::mcp_install_plan_from_changes(
137            target,
138            changes,
139            scope,
140            spec,
141            Some(cfg),
142        ))
143    }
144
145    fn plan_uninstall_mcp(
146        &self,
147        scope: &Scope,
148        name: &str,
149        owner_tag: &str,
150    ) -> Result<UninstallPlan, AgentConfigError> {
151        McpSpec::validate_name(name)?;
152        HookSpec::validate_tag(owner_tag)?;
153        let target = PlanTarget::Mcp {
154            integration_id: McpSurface::id(self),
155            scope: scope.clone(),
156            name: name.to_string(),
157            owner: owner_tag.to_string(),
158        };
159        let cfg = Self::config_toml_path(scope)?;
160        let ledger = ownership::mcp_ledger_for(&cfg);
161        let mut changes = Vec::new();
162        let mut doc = match toml_patch::read_or_empty(&cfg) {
163            Ok(doc) => doc,
164            Err(AgentConfigError::TomlInvalid { .. }) => {
165                changes.push(PlannedChange::Refuse {
166                    path: Some(cfg),
167                    reason: RefusalReason::InvalidConfig,
168                });
169                return Ok(UninstallPlan::from_changes(target, changes));
170            }
171            Err(e) => return Err(e),
172        };
173        let in_config = toml_patch::contains_named_table(&doc, &["mcp_servers"], name);
174        let actual_owner = ownership::owner_of(&ledger, name)?;
175        if !in_config && actual_owner.is_none() {
176            changes.push(PlannedChange::NoOp {
177                path: cfg,
178                reason: "mcp server is already absent".into(),
179            });
180            return Ok(UninstallPlan::from_changes(target, changes));
181        }
182        match (actual_owner.as_deref(), in_config) {
183            (Some(owner), _) if owner != owner_tag => {
184                changes.push(PlannedChange::Refuse {
185                    path: Some(ledger),
186                    reason: RefusalReason::OwnerMismatch,
187                });
188                return Ok(UninstallPlan::from_changes(target, changes));
189            }
190            (None, true) => {
191                changes.push(PlannedChange::Refuse {
192                    path: Some(cfg),
193                    reason: RefusalReason::UserInstalledEntry,
194                });
195                return Ok(UninstallPlan::from_changes(target, changes));
196            }
197            _ => {}
198        }
199
200        if in_config {
201            let removed = toml_patch::remove_named_table(&mut doc, &["mcp_servers"], name)?;
202            debug_assert!(removed);
203            if doc.as_table().is_empty() {
204                let bytes = toml_patch::to_string(&doc);
205                planning::plan_restore_backup_or_remove(&mut changes, &cfg, &bytes)?;
206            } else {
207                let bytes = toml_patch::to_string(&doc);
208                planning::plan_write_file(&mut changes, &cfg, &bytes, false)?;
209            }
210        }
211        if actual_owner.is_some() {
212            planning::plan_remove_ledger_entry(&mut changes, &ledger, name);
213        }
214        Ok(UninstallPlan::from_changes(target, changes))
215    }
216
217    fn install_mcp(
218        &self,
219        scope: &Scope,
220        spec: &McpSpec,
221    ) -> Result<InstallReport, AgentConfigError> {
222        spec.validate()?;
223        let mut report = InstallReport::default();
224        let cfg = Self::config_toml_path(scope)?;
225        spec.validate_local_secret_policy(scope)?;
226        scope.ensure_contained(&cfg)?;
227        let ledger = ownership::mcp_ledger_for(&cfg);
228
229        file_lock::with_lock(&cfg, || {
230            let mut doc = toml_patch::read_or_empty(&cfg)?;
231            let in_config = toml_patch::contains_named_table(&doc, &["mcp_servers"], &spec.name);
232            let prior_owner = ownership::owner_of(&ledger, &spec.name)?;
233            let adopting = spec.adopt_unowned && in_config && prior_owner.is_none();
234            ownership::require_owner_with_policy(
235                &ledger,
236                &spec.name,
237                &spec.owner_tag,
238                "mcp server",
239                in_config,
240                spec.adopt_unowned,
241            )?;
242
243            let table = build_mcp_table(spec);
244            let changed =
245                toml_patch::upsert_named_table(&mut doc, &["mcp_servers"], &spec.name, table)?;
246
247            let owner_changed = prior_owner.as_deref() != Some(spec.owner_tag.as_str());
248
249            let written_bytes: Option<Vec<u8>> = if changed {
250                let bytes = toml_patch::to_string(&doc);
251                let outcome = safe_fs::write(scope, &cfg, &bytes, true)?;
252                if outcome.existed {
253                    report.patched.push(outcome.path.clone());
254                } else {
255                    report.created.push(outcome.path.clone());
256                }
257                if let Some(b) = outcome.backup {
258                    report.backed_up.push(b);
259                }
260                Some(bytes)
261            } else {
262                None
263            };
264
265            if changed || owner_changed || adopting {
266                let hash = match written_bytes.as_deref() {
267                    Some(b) => Some(ownership::content_hash(b)),
268                    None => ownership::file_content_hash(&cfg)?,
269                };
270                ownership::record_install(&ledger, &spec.name, &spec.owner_tag, hash.as_deref())?;
271            }
272            if !changed && !owner_changed && !adopting {
273                report.already_installed = true;
274            }
275            Ok::<(), AgentConfigError>(())
276        })?;
277        Ok(report)
278    }
279
280    fn uninstall_mcp(
281        &self,
282        scope: &Scope,
283        name: &str,
284        owner_tag: &str,
285    ) -> Result<UninstallReport, AgentConfigError> {
286        McpSpec::validate_name(name)?;
287        HookSpec::validate_tag(owner_tag)?;
288        let mut report = UninstallReport::default();
289
290        let cfg = Self::config_toml_path(scope)?;
291        scope.ensure_contained(&cfg)?;
292        let ledger = ownership::mcp_ledger_for(&cfg);
293
294        if !cfg.exists() && !ledger.exists() {
295            report.not_installed = true;
296            return Ok(report);
297        }
298
299        file_lock::with_lock(&cfg, || {
300            let mut doc = toml_patch::read_or_empty(&cfg)?;
301            let in_config = toml_patch::contains_named_table(&doc, &["mcp_servers"], name);
302            let in_ledger = ownership::contains(&ledger, name)?;
303
304            if !in_config && !in_ledger {
305                report.not_installed = true;
306                return Ok(());
307            }
308
309            ownership::require_owner(&ledger, name, owner_tag, "mcp server", in_config)?;
310
311            if in_config {
312                let removed = toml_patch::remove_named_table(&mut doc, &["mcp_servers"], name)?;
313                debug_assert!(removed);
314
315                let now_empty = doc.as_table().is_empty();
316                let bytes = toml_patch::to_string(&doc);
317                if now_empty && safe_fs::restore_backup_if_matches(scope, &cfg, &bytes)? {
318                    report.restored.push(cfg.clone());
319                } else if now_empty {
320                    safe_fs::remove_file(scope, &cfg)?;
321                    report.removed.push(cfg.clone());
322                } else {
323                    safe_fs::write(scope, &cfg, &bytes, false)?;
324                    report.patched.push(cfg.clone());
325                }
326            }
327
328            ownership::record_uninstall(&ledger, name)?;
329            Ok::<(), AgentConfigError>(())
330        })?;
331
332        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
333            report.not_installed = true;
334        }
335        Ok(report)
336    }
337}
338
339/// Translate an [`McpSpec`] into a TOML `[mcp_servers.<name>]` sub-table.
340fn build_mcp_table(spec: &McpSpec) -> Table {
341    let mut t = Table::new();
342    match &spec.transport {
343        McpTransport::Stdio { command, args, env } => {
344            t["command"] = value(command.clone());
345            let mut arr = Array::new();
346            for a in args {
347                arr.push(a.clone());
348            }
349            t["args"] = value(arr);
350            if !env.is_empty() {
351                let mut env_t = InlineTable::new();
352                for (k, v) in env {
353                    env_t.insert(k, v.clone().into());
354                }
355                t["env"] = value(env_t);
356            }
357        }
358        McpTransport::Http { url, headers } => {
359            t["type"] = value("http");
360            t["url"] = value(url.clone());
361            if !headers.is_empty() {
362                let mut h = InlineTable::new();
363                for (k, v) in headers {
364                    h.insert(k, v.clone().into());
365                }
366                t["headers"] = value(h);
367            }
368        }
369        McpTransport::Sse { url, headers } => {
370            t["type"] = value("sse");
371            t["url"] = value(url.clone());
372            if !headers.is_empty() {
373                let mut h = InlineTable::new();
374                for (k, v) in headers {
375                    h.insert(k, v.clone().into());
376                }
377                t["headers"] = value(h);
378            }
379        }
380    }
381    t
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::integration::Integration;
388    use tempfile::tempdir;
389
390    fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
391        McpSpec::builder(name)
392            .owner(owner)
393            .stdio("npx", ["-y", "@example/server"])
394            .env("FOO", "bar")
395            .build()
396    }
397
398    fn read_toml(p: &std::path::Path) -> String {
399        std::fs::read_to_string(p).unwrap()
400    }
401
402    #[test]
403    fn install_mcp_writes_named_table_in_config_toml() {
404        let dir = tempdir().unwrap();
405        let agent = CodexAgent::new();
406        let scope = Scope::Local(dir.path().to_path_buf());
407        agent
408            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
409            .unwrap();
410        let cfg = dir.path().join(".codex/config.toml");
411        assert!(cfg.exists());
412        let s = read_toml(&cfg);
413        assert!(s.contains("[mcp_servers.github]"), "got:\n{s}");
414        assert!(s.contains(r#"command = "npx""#), "got:\n{s}");
415        assert!(s.contains(r#"FOO = "bar""#), "got:\n{s}");
416    }
417
418    #[test]
419    fn install_mcp_preserves_user_comments_and_other_sections() {
420        let dir = tempdir().unwrap();
421        let cfg = dir.path().join(".codex/config.toml");
422        std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
423        let original =
424            "# Codex configuration\n# Hand-authored.\n\n[some.section]\nkey = \"value\"\n";
425        std::fs::write(&cfg, original).unwrap();
426
427        let agent = CodexAgent::new();
428        let scope = Scope::Local(dir.path().to_path_buf());
429        agent
430            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
431            .unwrap();
432
433        let s = read_toml(&cfg);
434        assert!(
435            s.contains("# Codex configuration"),
436            "comment lost. got:\n{s}"
437        );
438        assert!(s.contains("[some.section]"), "user section lost");
439        assert!(s.contains("[mcp_servers.github]"));
440        // .bak made when we modified an existing file.
441        assert!(dir.path().join(".codex/config.toml.bak").exists());
442    }
443
444    #[test]
445    fn install_mcp_idempotent() {
446        let dir = tempdir().unwrap();
447        let agent = CodexAgent::new();
448        let scope = Scope::Local(dir.path().to_path_buf());
449        let s = local_mcp_spec("github", "myapp");
450        agent.install_mcp(&scope, &s).unwrap();
451        let r = agent.install_mcp(&scope, &s).unwrap();
452        assert!(r.already_installed);
453    }
454
455    #[test]
456    fn install_mcp_owner_mismatch_refused() {
457        let dir = tempdir().unwrap();
458        let agent = CodexAgent::new();
459        let scope = Scope::Local(dir.path().to_path_buf());
460        agent
461            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
462            .unwrap();
463        let err = agent
464            .install_mcp(&scope, &local_mcp_spec("github", "appB"))
465            .unwrap_err();
466        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
467    }
468
469    #[test]
470    fn install_mcp_refuses_hand_installed_same_name() {
471        let dir = tempdir().unwrap();
472        let cfg = dir.path().join(".codex/config.toml");
473        std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
474        std::fs::write(&cfg, "[mcp_servers.github]\ncommand = \"user-cmd\"\n").unwrap();
475
476        let agent = CodexAgent::new();
477        let scope = Scope::Local(dir.path().to_path_buf());
478        let err = agent
479            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
480            .unwrap_err();
481        assert!(matches!(
482            err,
483            AgentConfigError::NotOwnedByCaller { actual: None, .. }
484        ));
485        let s = read_toml(&cfg);
486        assert!(s.contains("user-cmd"));
487    }
488
489    #[test]
490    fn install_mcp_does_not_collide_with_hook_install() {
491        let dir = tempdir().unwrap();
492        let agent = CodexAgent::new();
493        let scope = Scope::Local(dir.path().to_path_buf());
494        let hook_spec = HookSpec::builder("alpha")
495            .command_program("myapp", ["hook"])
496            .build();
497        agent.install(&scope, &hook_spec).unwrap();
498        agent
499            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
500            .unwrap();
501        // Hooks use a separate file; both must exist.
502        assert!(dir.path().join(".codex/hooks.json").exists());
503        assert!(dir.path().join(".codex/config.toml").exists());
504    }
505
506    #[test]
507    fn uninstall_mcp_owner_mismatch_refused() {
508        let dir = tempdir().unwrap();
509        let agent = CodexAgent::new();
510        let scope = Scope::Local(dir.path().to_path_buf());
511        agent
512            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
513            .unwrap();
514        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
515        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
516    }
517
518    #[test]
519    fn uninstall_mcp_round_trip() {
520        let dir = tempdir().unwrap();
521        let agent = CodexAgent::new();
522        let scope = Scope::Local(dir.path().to_path_buf());
523        agent
524            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
525            .unwrap();
526        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
527        let cfg = dir.path().join(".codex/config.toml");
528        // Empty doc: the file is removed entirely.
529        assert!(!cfg.exists());
530    }
531
532    #[test]
533    fn uninstall_mcp_keeps_user_sections() {
534        let dir = tempdir().unwrap();
535        let cfg = dir.path().join(".codex/config.toml");
536        std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
537        let original = "[other]\nfoo = \"bar\"\n";
538        std::fs::write(&cfg, original).unwrap();
539        let agent = CodexAgent::new();
540        let scope = Scope::Local(dir.path().to_path_buf());
541        agent
542            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
543            .unwrap();
544        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
545        let s = read_toml(&cfg);
546        assert!(s.contains("[other]"), "got:\n{s}");
547        assert!(!s.contains("[mcp_servers"), "mcp_servers should be pruned");
548    }
549}