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 !codex_supports_transport(&spec.transport) {
78            return Ok(InstallPlan::refused(
79                target,
80                Some(cfg),
81                RefusalReason::UnsupportedTransport,
82            ));
83        }
84        if let Some(plan) = agent_planning::mcp_local_inline_secret_refusal(
85            target.clone(),
86            scope,
87            spec,
88            Some(cfg.clone()),
89        ) {
90            return Ok(plan);
91        }
92        let ledger = ownership::mcp_ledger_for(&cfg);
93        let mut changes = Vec::new();
94        let mut doc = match toml_patch::read_or_empty(&cfg) {
95            Ok(doc) => doc,
96            Err(AgentConfigError::TomlInvalid { .. }) => {
97                changes.push(PlannedChange::Refuse {
98                    path: Some(cfg),
99                    reason: RefusalReason::InvalidConfig,
100                });
101                return Ok(InstallPlan::from_changes(target, changes));
102            }
103            Err(e) => return Err(e),
104        };
105        let in_config = toml_patch::contains_named_table(&doc, &["mcp_servers"], &spec.name);
106        let prior_owner = ownership::owner_of(&ledger, &spec.name)?;
107        let adopting = spec.adopt_unowned && in_config && prior_owner.is_none();
108        match (prior_owner.as_deref(), in_config) {
109            (Some(owner), _) if owner != spec.owner_tag => {
110                changes.push(PlannedChange::Refuse {
111                    path: Some(ledger),
112                    reason: RefusalReason::OwnerMismatch,
113                });
114                return Ok(InstallPlan::from_changes(target, changes));
115            }
116            (None, true) if !spec.adopt_unowned => {
117                changes.push(PlannedChange::Refuse {
118                    path: Some(cfg),
119                    reason: RefusalReason::UserInstalledEntry,
120                });
121                return Ok(InstallPlan::from_changes(target, changes));
122            }
123            _ => {}
124        }
125
126        let table = build_mcp_table(spec)?;
127        let changed =
128            toml_patch::upsert_named_table(&mut doc, &["mcp_servers"], &spec.name, table)?;
129        let owner_changed = prior_owner.as_deref() != Some(spec.owner_tag.as_str());
130        if changed {
131            let bytes = toml_patch::to_string(&doc);
132            planning::plan_write_file(&mut changes, &cfg, &bytes, true)?;
133        }
134        if !has_refusal(&changes) && (changed || owner_changed || adopting) {
135            planning::plan_write_ledger(&mut changes, &ledger, &spec.name, &spec.owner_tag);
136        }
137        if changes.is_empty() {
138            changes.push(PlannedChange::NoOp {
139                path: cfg.clone(),
140                reason: "MCP server is already up to date".into(),
141            });
142        }
143        Ok(agent_planning::mcp_install_plan_from_changes(
144            target,
145            changes,
146            scope,
147            spec,
148            Some(cfg),
149        ))
150    }
151
152    fn plan_uninstall_mcp(
153        &self,
154        scope: &Scope,
155        name: &str,
156        owner_tag: &str,
157    ) -> Result<UninstallPlan, AgentConfigError> {
158        McpSpec::validate_name(name)?;
159        HookSpec::validate_tag(owner_tag)?;
160        let target = PlanTarget::Mcp {
161            integration_id: McpSurface::id(self),
162            scope: scope.clone(),
163            name: name.to_string(),
164            owner: owner_tag.to_string(),
165        };
166        let cfg = Self::config_toml_path(scope)?;
167        let ledger = ownership::mcp_ledger_for(&cfg);
168        let mut changes = Vec::new();
169        let mut doc = match toml_patch::read_or_empty(&cfg) {
170            Ok(doc) => doc,
171            Err(AgentConfigError::TomlInvalid { .. }) => {
172                changes.push(PlannedChange::Refuse {
173                    path: Some(cfg),
174                    reason: RefusalReason::InvalidConfig,
175                });
176                return Ok(UninstallPlan::from_changes(target, changes));
177            }
178            Err(e) => return Err(e),
179        };
180        let in_config = toml_patch::contains_named_table(&doc, &["mcp_servers"], name);
181        let actual_owner = ownership::owner_of(&ledger, name)?;
182        if !in_config && actual_owner.is_none() {
183            changes.push(PlannedChange::NoOp {
184                path: cfg,
185                reason: "mcp server is already absent".into(),
186            });
187            return Ok(UninstallPlan::from_changes(target, changes));
188        }
189        match (actual_owner.as_deref(), in_config) {
190            (Some(owner), _) if owner != owner_tag => {
191                changes.push(PlannedChange::Refuse {
192                    path: Some(ledger),
193                    reason: RefusalReason::OwnerMismatch,
194                });
195                return Ok(UninstallPlan::from_changes(target, changes));
196            }
197            (None, true) => {
198                changes.push(PlannedChange::Refuse {
199                    path: Some(cfg),
200                    reason: RefusalReason::UserInstalledEntry,
201                });
202                return Ok(UninstallPlan::from_changes(target, changes));
203            }
204            _ => {}
205        }
206
207        if in_config {
208            let removed = toml_patch::remove_named_table(&mut doc, &["mcp_servers"], name)?;
209            debug_assert!(removed);
210            if doc.as_table().is_empty() {
211                let bytes = toml_patch::to_string(&doc);
212                planning::plan_restore_backup_or_remove(&mut changes, &cfg, &bytes)?;
213            } else {
214                let bytes = toml_patch::to_string(&doc);
215                planning::plan_write_file(&mut changes, &cfg, &bytes, false)?;
216            }
217        }
218        if actual_owner.is_some() {
219            planning::plan_remove_ledger_entry(&mut changes, &ledger, name);
220        }
221        Ok(UninstallPlan::from_changes(target, changes))
222    }
223
224    fn install_mcp(
225        &self,
226        scope: &Scope,
227        spec: &McpSpec,
228    ) -> Result<InstallReport, AgentConfigError> {
229        spec.validate()?;
230        let mut report = InstallReport::default();
231        let cfg = Self::config_toml_path(scope)?;
232        validate_codex_transport(&spec.transport)?;
233        spec.validate_local_secret_policy(scope)?;
234        scope.ensure_contained(&cfg)?;
235        let ledger = ownership::mcp_ledger_for(&cfg);
236
237        file_lock::with_lock(&cfg, || {
238            let mut doc = toml_patch::read_or_empty(&cfg)?;
239            let in_config = toml_patch::contains_named_table(&doc, &["mcp_servers"], &spec.name);
240            let prior_owner = ownership::owner_of(&ledger, &spec.name)?;
241            let adopting = spec.adopt_unowned && in_config && prior_owner.is_none();
242            ownership::require_owner_with_policy(
243                &ledger,
244                &spec.name,
245                &spec.owner_tag,
246                "mcp server",
247                in_config,
248                spec.adopt_unowned,
249            )?;
250
251            let table = build_mcp_table(spec)?;
252            let changed =
253                toml_patch::upsert_named_table(&mut doc, &["mcp_servers"], &spec.name, table)?;
254
255            let owner_changed = prior_owner.as_deref() != Some(spec.owner_tag.as_str());
256
257            let written_bytes: Option<Vec<u8>> = if changed {
258                let bytes = toml_patch::to_string(&doc);
259                let outcome = safe_fs::write(scope, &cfg, &bytes, true)?;
260                if outcome.existed {
261                    report.patched.push(outcome.path.clone());
262                } else {
263                    report.created.push(outcome.path.clone());
264                }
265                if let Some(b) = outcome.backup {
266                    report.backed_up.push(b);
267                }
268                Some(bytes)
269            } else {
270                None
271            };
272
273            if changed || owner_changed || adopting {
274                let hash = match written_bytes.as_deref() {
275                    Some(b) => Some(ownership::content_hash(b)),
276                    None => ownership::file_content_hash(&cfg)?,
277                };
278                ownership::record_install(&ledger, &spec.name, &spec.owner_tag, hash.as_deref())?;
279            }
280            if !changed && !owner_changed && !adopting {
281                report.already_installed = true;
282            }
283            Ok::<(), AgentConfigError>(())
284        })?;
285        Ok(report)
286    }
287
288    fn uninstall_mcp(
289        &self,
290        scope: &Scope,
291        name: &str,
292        owner_tag: &str,
293    ) -> Result<UninstallReport, AgentConfigError> {
294        McpSpec::validate_name(name)?;
295        HookSpec::validate_tag(owner_tag)?;
296        let mut report = UninstallReport::default();
297
298        let cfg = Self::config_toml_path(scope)?;
299        scope.ensure_contained(&cfg)?;
300        let ledger = ownership::mcp_ledger_for(&cfg);
301
302        if !cfg.exists() && !ledger.exists() {
303            report.not_installed = true;
304            return Ok(report);
305        }
306
307        file_lock::with_lock(&cfg, || {
308            let mut doc = toml_patch::read_or_empty(&cfg)?;
309            let in_config = toml_patch::contains_named_table(&doc, &["mcp_servers"], name);
310            let in_ledger = ownership::contains(&ledger, name)?;
311
312            if !in_config && !in_ledger {
313                report.not_installed = true;
314                return Ok(());
315            }
316
317            ownership::require_owner(&ledger, name, owner_tag, "mcp server", in_config)?;
318
319            if in_config {
320                let removed = toml_patch::remove_named_table(&mut doc, &["mcp_servers"], name)?;
321                debug_assert!(removed);
322
323                let now_empty = doc.as_table().is_empty();
324                let bytes = toml_patch::to_string(&doc);
325                if now_empty && safe_fs::restore_backup_if_matches(scope, &cfg, &bytes)? {
326                    report.restored.push(cfg.clone());
327                } else if now_empty {
328                    safe_fs::remove_file(scope, &cfg)?;
329                    report.removed.push(cfg.clone());
330                } else {
331                    safe_fs::write(scope, &cfg, &bytes, false)?;
332                    report.patched.push(cfg.clone());
333                }
334            }
335
336            ownership::record_uninstall(&ledger, name)?;
337            Ok::<(), AgentConfigError>(())
338        })?;
339
340        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
341            report.not_installed = true;
342        }
343        Ok(report)
344    }
345}
346
347/// Translate an [`McpSpec`] into a TOML `[mcp_servers.<name>]` sub-table.
348fn build_mcp_table(spec: &McpSpec) -> Result<Table, AgentConfigError> {
349    let mut t = Table::new();
350    match &spec.transport {
351        McpTransport::Stdio { command, args, env } => {
352            t["command"] = value(command.clone());
353            let mut arr = Array::new();
354            for a in args {
355                arr.push(a.clone());
356            }
357            t["args"] = value(arr);
358            if !env.is_empty() {
359                let mut env_t = InlineTable::new();
360                for (k, v) in env {
361                    env_t.insert(k, v.clone().into());
362                }
363                t["env"] = value(env_t);
364            }
365        }
366        McpTransport::Http { url, headers } => {
367            t["url"] = value(url.clone());
368            if !headers.is_empty() {
369                let mut h = InlineTable::new();
370                for (k, v) in headers {
371                    h.insert(k, v.clone().into());
372                }
373                t["http_headers"] = value(h);
374            }
375        }
376        McpTransport::Sse { .. } => validate_codex_transport(&spec.transport)?,
377    }
378    Ok(t)
379}
380
381fn codex_supports_transport(transport: &McpTransport) -> bool {
382    !matches!(transport, McpTransport::Sse { .. })
383}
384
385fn validate_codex_transport(transport: &McpTransport) -> Result<(), AgentConfigError> {
386    if codex_supports_transport(transport) {
387        return Ok(());
388    }
389    Err(AgentConfigError::UnsupportedTransport {
390        id: "codex",
391        transport: "sse",
392        reason: "Codex MCP config supports stdio and streamable HTTP servers, not SSE",
393    })
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::integration::Integration;
400    use crate::plan::{PlanStatus, PlannedChange, RefusalReason};
401    use tempfile::tempdir;
402
403    fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
404        McpSpec::builder(name)
405            .owner(owner)
406            .stdio("npx", ["-y", "@example/server"])
407            .env("FOO", "bar")
408            .build()
409    }
410
411    fn read_toml(p: &std::path::Path) -> String {
412        std::fs::read_to_string(p).unwrap()
413    }
414
415    #[test]
416    fn install_mcp_writes_named_table_in_config_toml() {
417        let dir = tempdir().unwrap();
418        let agent = CodexAgent::new();
419        let scope = Scope::Local(dir.path().to_path_buf());
420        agent
421            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
422            .unwrap();
423        let cfg = dir.path().join(".codex/config.toml");
424        assert!(cfg.exists());
425        let s = read_toml(&cfg);
426        assert!(s.contains("[mcp_servers.github]"), "got:\n{s}");
427        assert!(s.contains(r#"command = "npx""#), "got:\n{s}");
428        assert!(s.contains(r#"FOO = "bar""#), "got:\n{s}");
429    }
430
431    #[test]
432    fn install_mcp_http_uses_codex_http_headers_shape() {
433        let dir = tempdir().unwrap();
434        let agent = CodexAgent::new();
435        let scope = Scope::Local(dir.path().to_path_buf());
436        let spec = McpSpec::builder("remote")
437            .owner("myapp")
438            .http("https://example.test/mcp")
439            .header("Authorization", "Bearer token")
440            .allow_local_inline_secrets()
441            .build();
442
443        agent.install_mcp(&scope, &spec).unwrap();
444
445        let cfg = dir.path().join(".codex/config.toml");
446        let s = read_toml(&cfg);
447        assert!(s.contains("[mcp_servers.remote]"), "got:\n{s}");
448        assert!(
449            s.contains(r#"url = "https://example.test/mcp""#),
450            "got:\n{s}"
451        );
452        assert!(
453            s.contains(r#"http_headers = { Authorization = "Bearer token" }"#),
454            "got:\n{s}"
455        );
456        assert!(!s.contains(r#"type = "http""#), "got:\n{s}");
457        assert!(!s.contains("\nheaders ="), "got:\n{s}");
458    }
459
460    #[test]
461    fn install_mcp_refuses_sse_transport() {
462        let dir = tempdir().unwrap();
463        let agent = CodexAgent::new();
464        let scope = Scope::Local(dir.path().to_path_buf());
465        let spec = McpSpec::builder("events")
466            .owner("myapp")
467            .sse("https://example.test/sse")
468            .build();
469
470        let err = agent.install_mcp(&scope, &spec).unwrap_err();
471
472        assert!(matches!(
473            err,
474            AgentConfigError::UnsupportedTransport {
475                id: "codex",
476                transport: "sse",
477                ..
478            }
479        ));
480        assert!(!dir.path().join(".codex/config.toml").exists());
481    }
482
483    #[test]
484    fn plan_install_mcp_refuses_sse_transport() {
485        let dir = tempdir().unwrap();
486        let agent = CodexAgent::new();
487        let scope = Scope::Local(dir.path().to_path_buf());
488        let spec = McpSpec::builder("events")
489            .owner("myapp")
490            .sse("https://example.test/sse")
491            .build();
492
493        let plan = agent.plan_install_mcp(&scope, &spec).unwrap();
494
495        assert_eq!(plan.status, PlanStatus::Refused);
496        assert!(matches!(
497            plan.changes.as_slice(),
498            [PlannedChange::Refuse {
499                reason: RefusalReason::UnsupportedTransport,
500                ..
501            }]
502        ));
503        assert!(!dir.path().join(".codex/config.toml").exists());
504    }
505
506    #[test]
507    fn install_mcp_preserves_user_comments_and_other_sections() {
508        let dir = tempdir().unwrap();
509        let cfg = dir.path().join(".codex/config.toml");
510        std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
511        let original =
512            "# Codex configuration\n# Hand-authored.\n\n[some.section]\nkey = \"value\"\n";
513        std::fs::write(&cfg, original).unwrap();
514
515        let agent = CodexAgent::new();
516        let scope = Scope::Local(dir.path().to_path_buf());
517        agent
518            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
519            .unwrap();
520
521        let s = read_toml(&cfg);
522        assert!(
523            s.contains("# Codex configuration"),
524            "comment lost. got:\n{s}"
525        );
526        assert!(s.contains("[some.section]"), "user section lost");
527        assert!(s.contains("[mcp_servers.github]"));
528        // .bak made when we modified an existing file.
529        assert!(dir.path().join(".codex/config.toml.bak").exists());
530    }
531
532    #[test]
533    fn install_mcp_idempotent() {
534        let dir = tempdir().unwrap();
535        let agent = CodexAgent::new();
536        let scope = Scope::Local(dir.path().to_path_buf());
537        let s = local_mcp_spec("github", "myapp");
538        agent.install_mcp(&scope, &s).unwrap();
539        let r = agent.install_mcp(&scope, &s).unwrap();
540        assert!(r.already_installed);
541    }
542
543    #[test]
544    fn install_mcp_owner_mismatch_refused() {
545        let dir = tempdir().unwrap();
546        let agent = CodexAgent::new();
547        let scope = Scope::Local(dir.path().to_path_buf());
548        agent
549            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
550            .unwrap();
551        let err = agent
552            .install_mcp(&scope, &local_mcp_spec("github", "appB"))
553            .unwrap_err();
554        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
555    }
556
557    #[test]
558    fn install_mcp_refuses_hand_installed_same_name() {
559        let dir = tempdir().unwrap();
560        let cfg = dir.path().join(".codex/config.toml");
561        std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
562        std::fs::write(&cfg, "[mcp_servers.github]\ncommand = \"user-cmd\"\n").unwrap();
563
564        let agent = CodexAgent::new();
565        let scope = Scope::Local(dir.path().to_path_buf());
566        let err = agent
567            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
568            .unwrap_err();
569        assert!(matches!(
570            err,
571            AgentConfigError::NotOwnedByCaller { actual: None, .. }
572        ));
573        let s = read_toml(&cfg);
574        assert!(s.contains("user-cmd"));
575    }
576
577    #[test]
578    fn install_mcp_does_not_collide_with_hook_install() {
579        let dir = tempdir().unwrap();
580        let agent = CodexAgent::new();
581        let scope = Scope::Local(dir.path().to_path_buf());
582        let hook_spec = HookSpec::builder("alpha")
583            .command_program("myapp", ["hook"])
584            .build();
585        agent.install(&scope, &hook_spec).unwrap();
586        agent
587            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
588            .unwrap();
589        // Hooks use a separate file; both must exist.
590        assert!(dir.path().join(".codex/hooks.json").exists());
591        assert!(dir.path().join(".codex/config.toml").exists());
592    }
593
594    #[test]
595    fn uninstall_mcp_owner_mismatch_refused() {
596        let dir = tempdir().unwrap();
597        let agent = CodexAgent::new();
598        let scope = Scope::Local(dir.path().to_path_buf());
599        agent
600            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
601            .unwrap();
602        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
603        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
604    }
605
606    #[test]
607    fn uninstall_mcp_round_trip() {
608        let dir = tempdir().unwrap();
609        let agent = CodexAgent::new();
610        let scope = Scope::Local(dir.path().to_path_buf());
611        agent
612            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
613            .unwrap();
614        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
615        let cfg = dir.path().join(".codex/config.toml");
616        // Empty doc: the file is removed entirely.
617        assert!(!cfg.exists());
618    }
619
620    #[test]
621    fn uninstall_mcp_keeps_user_sections() {
622        let dir = tempdir().unwrap();
623        let cfg = dir.path().join(".codex/config.toml");
624        std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
625        let original = "[other]\nfoo = \"bar\"\n";
626        std::fs::write(&cfg, original).unwrap();
627        let agent = CodexAgent::new();
628        let scope = Scope::Local(dir.path().to_path_buf());
629        agent
630            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
631            .unwrap();
632        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
633        let s = read_toml(&cfg);
634        assert!(s.contains("[other]"), "got:\n{s}");
635        assert!(!s.contains("[mcp_servers"), "mcp_servers should be pruned");
636    }
637}