1use 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 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
347fn 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 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 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 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}