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 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
339fn 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 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 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 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}