1use std::path::Path;
2
3use crate::error::{Result, TuffError};
4use crate::policy::{OPENCODE_SCHEMA, OrderedJson, is_opencode_config};
5
6pub fn servers_key(mcp_config_path: &Path) -> &'static str {
10 if is_toml(mcp_config_path) {
11 "mcp_servers"
12 } else if is_opencode(mcp_config_path) {
13 "mcp"
14 } else {
15 "mcpServers"
16 }
17}
18
19pub fn is_toml(mcp_config_path: &Path) -> bool {
23 mcp_config_path
24 .extension()
25 .is_some_and(|extension| extension == "toml")
26}
27
28const POLICY_KEYS: [&str; 2] = ["disabled_tools", "tools"];
32
33pub fn toml_entry(mcp_config_path: &Path, server_id: &str) -> Result<Option<serde_json::Value>> {
38 if !mcp_config_path.is_file() {
39 return Ok(None);
40 }
41 let raw = std::fs::read_to_string(mcp_config_path)?;
42 let document: toml::Value = toml::from_str(&raw)
43 .map_err(|error| corrupt(mcp_config_path, &format!("not valid TOML: {error}")))?;
44 let Some(entry) = document
45 .get("mcp_servers")
46 .and_then(|servers| servers.get(server_id))
47 else {
48 return Ok(None);
49 };
50 let mut entry = serde_json::to_value(entry)?;
51 if let Some(table) = entry.as_object_mut() {
52 for key in POLICY_KEYS {
53 table.remove(key);
54 }
55 }
56 Ok(Some(entry))
57}
58
59fn read_toml_document(mcp_config_path: &Path) -> Result<toml_edit::DocumentMut> {
60 let raw = if mcp_config_path.exists() {
61 std::fs::read_to_string(mcp_config_path)?
62 } else {
63 String::new()
64 };
65 let document: toml_edit::DocumentMut = raw
66 .parse()
67 .map_err(|error| corrupt(mcp_config_path, &format!("not valid TOML: {error}")))?;
68 if document
69 .get("mcp_servers")
70 .is_some_and(|servers| !servers.is_table_like())
71 {
72 return Err(corrupt(
73 mcp_config_path,
74 "field 'mcp_servers' must be a table",
75 ));
76 }
77 Ok(document)
78}
79
80fn toml_table_from_json(
84 entry: &serde_json::Value,
85 mcp_config_path: &Path,
86) -> Result<toml_edit::Table> {
87 let serde_json::Value::Object(fields) = entry else {
88 return Err(corrupt(mcp_config_path, "an MCP entry must be an object"));
89 };
90 const FIRST: [&str; 3] = ["command", "args", "url"];
94 let mut table = toml_edit::Table::new();
95 let ordered = FIRST
96 .iter()
97 .filter_map(|key| fields.get_key_value(*key))
98 .chain(
99 fields
100 .iter()
101 .filter(|(key, _)| !FIRST.contains(&key.as_str())),
102 );
103 for (key, value) in ordered {
104 table.insert(
105 key,
106 toml_edit::Item::Value(toml_value_from_json(value, mcp_config_path)?),
107 );
108 }
109 Ok(table)
110}
111
112fn toml_value_from_json(
113 value: &serde_json::Value,
114 mcp_config_path: &Path,
115) -> Result<toml_edit::Value> {
116 Ok(match value {
117 serde_json::Value::String(text) => toml_edit::Value::from(text.as_str()),
118 serde_json::Value::Bool(flag) => toml_edit::Value::from(*flag),
119 serde_json::Value::Number(number) => match (number.as_i64(), number.as_f64()) {
120 (Some(integer), _) => toml_edit::Value::from(integer),
121 (None, Some(float)) => toml_edit::Value::from(float),
122 (None, None) => {
123 return Err(corrupt(
124 mcp_config_path,
125 "an MCP entry holds a number TOML cannot represent",
126 ));
127 }
128 },
129 serde_json::Value::Array(items) => {
130 let mut array = toml_edit::Array::new();
131 for item in items {
132 array.push(toml_value_from_json(item, mcp_config_path)?);
133 }
134 toml_edit::Value::Array(array)
135 }
136 serde_json::Value::Object(fields) => {
137 let mut inline = toml_edit::InlineTable::new();
138 for (key, value) in fields {
139 inline.insert(key, toml_value_from_json(value, mcp_config_path)?);
140 }
141 toml_edit::Value::InlineTable(inline)
142 }
143 serde_json::Value::Null => {
144 return Err(corrupt(
145 mcp_config_path,
146 "an MCP entry holds a null, which TOML cannot represent",
147 ));
148 }
149 })
150}
151
152fn register_server_toml(
155 mcp_config_path: &Path,
156 server_id: &str,
157 entry: serde_json::Value,
158 allow_overwrite: bool,
159) -> Result<()> {
160 let mut document = read_toml_document(mcp_config_path)?;
161 let mut table = toml_table_from_json(&entry, mcp_config_path)?;
162 let servers = document
163 .entry("mcp_servers")
164 .or_insert_with(|| {
165 let mut parent = toml_edit::Table::new();
166 parent.set_implicit(true);
167 toml_edit::Item::Table(parent)
168 })
169 .as_table_like_mut()
170 .expect("read_toml_document validates mcp_servers");
171 if !allow_overwrite && servers.contains_key(server_id) {
172 return Err(untracked_server(mcp_config_path, server_id));
173 }
174 if let Some(previous) = servers
175 .get(server_id)
176 .and_then(toml_edit::Item::as_table_like)
177 {
178 for key in POLICY_KEYS {
179 if let Some(item) = previous.get(key) {
180 table.insert(key, item.clone());
181 }
182 }
183 }
184 servers.insert(server_id, toml_edit::Item::Table(table));
185 if let Some(parent) = mcp_config_path.parent() {
186 std::fs::create_dir_all(parent)?;
187 }
188 std::fs::write(mcp_config_path, document.to_string())?;
189 Ok(())
190}
191
192fn remove_server_toml(mcp_config_path: &Path, server_id: &str) -> Result<()> {
195 let mut document = read_toml_document(mcp_config_path)?;
196 let Some(servers) = document
197 .get_mut("mcp_servers")
198 .and_then(|item| item.as_table_like_mut())
199 else {
200 return Ok(());
201 };
202 if servers.remove(server_id).is_none() {
203 return Ok(());
204 }
205 if servers.is_empty() {
206 document.remove("mcp_servers");
207 }
208 if document.as_table().is_empty() && document.to_string().trim().is_empty() {
209 std::fs::remove_file(mcp_config_path)?;
210 return Ok(());
211 }
212 std::fs::write(mcp_config_path, document.to_string())?;
213 Ok(())
214}
215
216fn has_server_toml(mcp_config_path: &Path, server_id: &str) -> Result<bool> {
217 let document = read_toml_document(mcp_config_path)?;
218 Ok(document
219 .get("mcp_servers")
220 .and_then(|servers| servers.as_table_like())
221 .is_some_and(|servers| servers.contains_key(server_id)))
222}
223
224fn is_opencode(mcp_config_path: &Path) -> bool {
227 mcp_config_path.to_str().is_some_and(is_opencode_config)
228}
229
230pub fn validate_config(mcp_config_path: &Path) -> Result<()> {
231 if is_toml(mcp_config_path) {
232 return read_toml_document(mcp_config_path).map(|_| ());
233 }
234 read_config(mcp_config_path).map(|_| ())
235}
236
237pub fn register_tool(
238 mcp_config_path: &Path,
239 tool_id: &str,
240 command: &str,
241 args: &[String],
242) -> Result<()> {
243 register_server(
244 mcp_config_path,
245 tool_id,
246 serde_json::json!({"command": command, "args": args}),
247 true,
248 )
249}
250
251pub fn register_server(
261 mcp_config_path: &Path,
262 server_id: &str,
263 entry: serde_json::Value,
264 allow_overwrite: bool,
265) -> Result<()> {
266 if is_toml(mcp_config_path) {
267 return register_server_toml(mcp_config_path, server_id, entry, allow_overwrite);
268 }
269 if is_opencode(mcp_config_path) {
270 return register_server_ordered(mcp_config_path, server_id, entry, allow_overwrite);
271 }
272 let key = servers_key(mcp_config_path);
273 let mut config = read_config(mcp_config_path)?;
274 let config_object = config
275 .as_object_mut()
276 .expect("read_config returns a JSON object");
277 let servers = config_object
278 .entry(key)
279 .or_insert_with(|| serde_json::json!({}))
280 .as_object_mut()
281 .expect("read_config validates the servers object");
282
283 if !allow_overwrite && servers.contains_key(server_id) {
284 return Err(untracked_server(mcp_config_path, server_id));
285 }
286
287 servers.insert(server_id.to_string(), entry);
288
289 if let Some(parent) = mcp_config_path.parent() {
290 std::fs::create_dir_all(parent)?;
291 }
292 write_config(mcp_config_path, &config)
293}
294
295fn untracked_server(mcp_config_path: &Path, server_id: &str) -> TuffError {
296 TuffError::refused(format!(
297 "refusing to overwrite untracked MCP server '{}' in {}",
298 server_id,
299 mcp_config_path.display()
300 ))
301 .with_hint("remove it by hand, or choose a different capability id")
302}
303
304fn register_server_ordered(
308 mcp_config_path: &Path,
309 server_id: &str,
310 entry: serde_json::Value,
311 allow_overwrite: bool,
312) -> Result<()> {
313 let mut root = read_ordered(mcp_config_path)?;
314 let OrderedJson::Object(object) = &mut root else {
315 return Err(corrupt(mcp_config_path, "root must be a JSON object"));
316 };
317 let servers = object
318 .entry("mcp".to_string())
319 .or_insert_with(|| OrderedJson::Object(indexmap::IndexMap::new()));
320 let OrderedJson::Object(servers) = servers else {
321 return Err(corrupt(
322 mcp_config_path,
323 "field 'mcp' must be a JSON object",
324 ));
325 };
326 if !allow_overwrite && servers.contains_key(server_id) {
327 return Err(untracked_server(mcp_config_path, server_id));
328 }
329 let entry: OrderedJson = serde_json::from_value(entry)?;
330 match servers.get_mut(server_id) {
331 Some(existing) => *existing = entry,
332 None => {
333 servers.insert(server_id.to_string(), entry);
334 }
335 }
336 if let Some(parent) = mcp_config_path.parent() {
337 std::fs::create_dir_all(parent)?;
338 }
339 write_ordered(mcp_config_path, &root)
340}
341
342fn remove_server_ordered(mcp_config_path: &Path, server_id: &str) -> Result<()> {
345 let mut root = read_ordered(mcp_config_path)?;
346 let OrderedJson::Object(object) = &mut root else {
347 return Err(corrupt(mcp_config_path, "root must be a JSON object"));
348 };
349 let Some(OrderedJson::Object(servers)) = object.get_mut("mcp") else {
350 return Ok(());
351 };
352 if servers.shift_remove(server_id).is_none() {
353 return Ok(());
354 }
355 if servers.is_empty() {
356 object.shift_remove("mcp");
357 }
358 if object.keys().all(|key| key == "$schema") {
359 std::fs::remove_file(mcp_config_path)?;
360 return Ok(());
361 }
362 write_ordered(mcp_config_path, &root)
363}
364
365fn read_ordered(mcp_config_path: &Path) -> Result<OrderedJson> {
366 let raw = if mcp_config_path.exists() {
367 std::fs::read_to_string(mcp_config_path)?
368 } else {
369 String::new()
370 };
371 if raw.trim().is_empty() {
372 return Ok(OrderedJson::Object(indexmap::IndexMap::from([(
373 "$schema".to_string(),
374 OrderedJson::Scalar(serde_json::Value::String(OPENCODE_SCHEMA.to_string())),
375 )])));
376 }
377 let root: OrderedJson =
378 serde_json::from_str(&raw).map_err(|error| corrupt(mcp_config_path, &error.to_string()))?;
379 let OrderedJson::Object(object) = &root else {
380 return Err(corrupt(mcp_config_path, "root must be a JSON object"));
381 };
382 if object
383 .get("mcp")
384 .is_some_and(|servers| !matches!(servers, OrderedJson::Object(_)))
385 {
386 return Err(corrupt(
387 mcp_config_path,
388 "field 'mcp' must be a JSON object",
389 ));
390 }
391 Ok(root)
392}
393
394fn write_ordered(mcp_config_path: &Path, root: &OrderedJson) -> Result<()> {
395 std::fs::write(mcp_config_path, serde_json::to_string_pretty(root)? + "\n")?;
396 Ok(())
397}
398
399fn corrupt(mcp_config_path: &Path, detail: &str) -> TuffError {
400 TuffError::corrupt(format!(
401 "invalid MCP config at {}: {detail}",
402 mcp_config_path.display()
403 ))
404}
405
406pub fn has_server(mcp_config_path: &Path, server_id: &str) -> Result<bool> {
410 if !mcp_config_path.exists() {
411 return Ok(false);
412 }
413 if is_toml(mcp_config_path) {
414 return has_server_toml(mcp_config_path, server_id);
415 }
416 let config = read_config(mcp_config_path)?;
417 Ok(config
418 .get(servers_key(mcp_config_path))
419 .and_then(serde_json::Value::as_object)
420 .is_some_and(|servers| servers.contains_key(server_id)))
421}
422
423pub fn remove_tool(mcp_config_path: &Path, tool_id: &str) -> Result<()> {
424 if !mcp_config_path.exists() {
425 return Ok(());
426 }
427 if is_toml(mcp_config_path) {
428 return remove_server_toml(mcp_config_path, tool_id);
429 }
430 if is_opencode(mcp_config_path) {
431 return remove_server_ordered(mcp_config_path, tool_id);
432 }
433
434 let key = servers_key(mcp_config_path);
435 let mut config = read_config(mcp_config_path)?;
436 let Some(servers) = config
437 .as_object_mut()
438 .and_then(|object| object.get_mut(key))
439 .and_then(serde_json::Value::as_object_mut)
440 else {
441 return Ok(());
442 };
443 if servers.remove(tool_id).is_none() {
444 return Ok(());
445 }
446
447 write_config(mcp_config_path, &config)
448}
449
450fn read_config(mcp_config_path: &Path) -> Result<serde_json::Value> {
451 let config = if mcp_config_path.exists() {
452 let raw = std::fs::read_to_string(mcp_config_path)?;
453 if raw.trim().is_empty() {
454 serde_json::json!({})
455 } else {
456 serde_json::from_str(&raw).map_err(|error| {
457 TuffError::corrupt(format!(
458 "invalid MCP config at {}: {error}",
459 mcp_config_path.display()
460 ))
461 })?
462 }
463 } else {
464 serde_json::json!({})
465 };
466
467 let object = config.as_object().ok_or_else(|| {
468 TuffError::corrupt(format!(
469 "invalid MCP config at {}: root must be a JSON object",
470 mcp_config_path.display()
471 ))
472 })?;
473 let key = servers_key(mcp_config_path);
474 if object.get(key).is_some_and(|servers| !servers.is_object()) {
475 return Err(TuffError::corrupt(format!(
476 "invalid MCP config at {}: field '{key}' must be a JSON object",
477 mcp_config_path.display()
478 )));
479 }
480
481 Ok(config)
482}
483
484fn write_config(mcp_config_path: &Path, config: &serde_json::Value) -> Result<()> {
485 std::fs::write(
486 mcp_config_path,
487 serde_json::to_string_pretty(config)? + "\n",
488 )?;
489 Ok(())
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn register_rejects_malformed_json_without_changing_it() {
498 let temp = tempfile::tempdir().expect("tempdir");
499 let path = temp.path().join("mcp.json");
500 let original = b"{ not-json\n";
501 std::fs::write(&path, original).expect("write config");
502
503 let error = register_tool(&path, "demo", "python", &[]).expect_err("invalid config");
504
505 assert!(error.to_string().contains("invalid MCP config"));
506 assert_eq!(std::fs::read(&path).expect("read config"), original);
507 }
508
509 #[test]
510 fn remove_rejects_malformed_json_without_changing_it() {
511 let temp = tempfile::tempdir().expect("tempdir");
512 let path = temp.path().join("mcp.json");
513 let original = b"[invalid";
514 std::fs::write(&path, original).expect("write config");
515
516 let error = remove_tool(&path, "demo").expect_err("invalid config");
517
518 assert!(error.to_string().contains("invalid MCP config"));
519 assert_eq!(std::fs::read(&path).expect("read config"), original);
520 }
521
522 #[test]
523 fn rewriting_a_toml_server_keeps_the_settings_policy_rules_own() {
524 let temp = tempfile::tempdir().expect("tempdir");
525 let path = temp.path().join("config.toml");
526 register_server(
527 &path,
528 "github",
529 serde_json::json!({"command": "old"}),
530 false,
531 )
532 .unwrap();
533 let raw = std::fs::read_to_string(&path).unwrap();
534 std::fs::write(
535 &path,
536 format!("{raw}disabled_tools = [\"delete_repo\"]\n\n[mcp_servers.github.tools.merge]\napproval_mode = \"prompt\"\n"),
537 )
538 .unwrap();
539 assert_eq!(
540 toml_entry(&path, "github").unwrap(),
541 Some(serde_json::json!({"command": "old"})),
542 "the entry's hash leaves policy settings out"
543 );
544
545 register_server(&path, "github", serde_json::json!({"command": "new"}), true).unwrap();
546 let parsed: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
547 let github = &parsed["mcp_servers"]["github"];
548 assert_eq!(github["command"].as_str(), Some("new"));
549 assert_eq!(github["disabled_tools"][0].as_str(), Some("delete_repo"));
550 assert_eq!(
551 github["tools"]["merge"]["approval_mode"].as_str(),
552 Some("prompt")
553 );
554 }
555
556 #[test]
557 fn a_toml_config_keeps_the_users_lines_and_loses_mcp_servers_when_emptied() {
558 let temp = tempfile::tempdir().expect("tempdir");
559 let path = temp.path().join(".codex").join("config.toml");
560 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
561 let original = "# Codex project settings\nmodel = \"gpt-5-codex\"\n\n[mcp_servers.mine]\ncommand = \"node\"\nargs = [\"server.js\"]\n";
562 std::fs::write(&path, original).unwrap();
563
564 register_server(
565 &path,
566 "everything",
567 serde_json::json!({
568 "command": "npx",
569 "args": ["-y", "pkg"],
570 "env_vars": ["TOKEN"],
571 "env_http_headers": {"X-Key": "KEY"},
572 }),
573 false,
574 )
575 .unwrap();
576 let raw = std::fs::read_to_string(&path).unwrap();
577 assert!(
578 raw.starts_with("# Codex project settings\nmodel = \"gpt-5-codex\"\n"),
579 "{raw}"
580 );
581 assert!(
582 raw.contains("[mcp_servers.mine]\ncommand = \"node\""),
583 "{raw}"
584 );
585 assert!(raw.contains("[mcp_servers.everything]"), "{raw}");
586 assert!(raw.contains("env_vars = [\"TOKEN\"]"), "{raw}");
587 assert!(raw.contains("X-Key = \"KEY\""), "{raw}");
588 assert!(
589 raw.contains("[mcp_servers.everything]\ncommand = \"npx\"\nargs = [\"-y\", \"pkg\"]\n"),
590 "the launch line comes first: {raw}"
591 );
592 assert!(has_server(&path, "everything").unwrap());
593 assert_eq!(
594 toml_entry(&path, "everything").unwrap(),
595 Some(serde_json::json!({
596 "command": "npx",
597 "args": ["-y", "pkg"],
598 "env_vars": ["TOKEN"],
599 "env_http_headers": {"X-Key": "KEY"},
600 })),
601 "the entry reads back as the value it was written from"
602 );
603 let error =
604 register_server(&path, "mine", serde_json::json!({}), false).expect_err("untracked");
605 assert!(
606 error.to_string().contains("refusing to overwrite"),
607 "{error}"
608 );
609
610 remove_tool(&path, "everything").unwrap();
611 let after = std::fs::read_to_string(&path).unwrap();
612 assert!(after.contains("[mcp_servers.mine]"), "{after}");
613 assert!(!after.contains("everything"), "{after}");
614 assert!(after.starts_with("# Codex project settings"), "{after}");
615
616 let fresh = temp.path().join("fresh").join("config.toml");
618 register_server(
619 &fresh,
620 "s",
621 serde_json::json!({"command": "x", "args": []}),
622 false,
623 )
624 .unwrap();
625 assert!(
626 std::fs::read_to_string(&fresh)
627 .unwrap()
628 .contains("[mcp_servers.s]")
629 );
630 remove_tool(&fresh, "s").unwrap();
631 assert!(!fresh.exists());
632
633 let bad = temp.path().join("bad").join("config.toml");
634 std::fs::create_dir_all(bad.parent().unwrap()).unwrap();
635 std::fs::write(&bad, "mcp_servers = 3\n").unwrap();
636 let error = validate_config(&bad).expect_err("not a table");
637 assert!(
638 error.to_string().contains("'mcp_servers' must be a table"),
639 "{error}"
640 );
641 }
642
643 #[test]
644 fn an_opencode_config_keeps_its_key_order_and_loses_mcp_when_emptied() {
645 let temp = tempfile::tempdir().expect("tempdir");
646 let path = temp.path().join(".opencode").join("opencode.json");
647 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
648 let original = "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"permission\": {\n \"bash\": {\n \"*\": \"allow\",\n \"git push *\": \"allow\"\n }\n },\n \"model\": \"x\"\n}\n";
650 std::fs::write(&path, original).unwrap();
651
652 register_server(
653 &path,
654 "everything",
655 serde_json::json!({"type": "local", "command": ["npx", "-y", "pkg"]}),
656 false,
657 )
658 .unwrap();
659 let raw = std::fs::read_to_string(&path).unwrap();
660 let position = |needle: &str| raw.find(needle).unwrap_or_else(|| panic!("{needle}"));
661 assert!(
662 position("\"$schema\"") < position("\"permission\""),
663 "{raw}"
664 );
665 assert!(
666 position("\"*\": \"allow\"") < position("\"git push *\""),
667 "{raw}"
668 );
669 assert!(
670 position("\"model\"") < position("\"mcp\""),
671 "new keys go last: {raw}"
672 );
673 assert!(!raw.contains("mcpServers"), "{raw}");
674 assert!(has_server(&path, "everything").unwrap());
675
676 let error = register_server(&path, "everything", serde_json::json!({}), false)
677 .expect_err("untracked entry");
678 assert!(
679 error.to_string().contains("refusing to overwrite"),
680 "{error}"
681 );
682
683 remove_tool(&path, "everything").unwrap();
684 let after = std::fs::read_to_string(&path).unwrap();
685 assert!(
686 !after.contains("\"mcp\""),
687 "an emptied mcp object is removed: {after}"
688 );
689 assert!(after.contains("\"model\": \"x\""), "{after}");
690
691 let fresh = temp.path().join("fresh").join("opencode.json");
693 register_server(&fresh, "s", serde_json::json!({"type": "local"}), false).unwrap();
694 let created: serde_json::Value =
695 serde_json::from_str(&std::fs::read_to_string(&fresh).unwrap()).unwrap();
696 assert_eq!(created["$schema"], "https://opencode.ai/config.json");
697 remove_tool(&fresh, "s").unwrap();
698 assert!(!fresh.exists());
699 }
700
701 #[test]
702 fn register_preserves_unrelated_fields() {
703 let temp = tempfile::tempdir().expect("tempdir");
704 let path = temp.path().join("mcp.json");
705 std::fs::write(
706 &path,
707 r#"{"custom":{"enabled":true},"mcpServers":{"existing":{"command":"node"}}}"#,
708 )
709 .expect("write config");
710
711 register_tool(&path, "demo", "python", &["server.py".to_string()]).expect("register tool");
712
713 let config: serde_json::Value =
714 serde_json::from_slice(&std::fs::read(path).expect("read config"))
715 .expect("parse config");
716 assert_eq!(config["custom"]["enabled"], true);
717 assert_eq!(config["mcpServers"]["existing"]["command"], "node");
718 assert_eq!(config["mcpServers"]["demo"]["command"], "python");
719 }
720
721 #[test]
722 fn validation_rejects_non_object_mcp_servers() {
723 let temp = tempfile::tempdir().expect("tempdir");
724 let path = temp.path().join("mcp.json");
725 std::fs::write(&path, r#"{"mcpServers":[]}"#).expect("write config");
726
727 let error = validate_config(&path).expect_err("invalid mcpServers");
728
729 assert!(
730 error
731 .to_string()
732 .contains("'mcpServers' must be a JSON object")
733 );
734 }
735
736 #[test]
737 fn register_server_writes_entry_and_preserves_neighbours() {
738 let tmp = tempfile::TempDir::new().unwrap();
739 let path = tmp.path().join("mcp.json");
740 std::fs::write(
741 &path,
742 "{\"custom\":true,\"mcpServers\":{\"other\":{\"command\":\"x\"}}}",
743 )
744 .unwrap();
745
746 register_server(
747 &path,
748 "github",
749 serde_json::json!({"command": "npx", "args": ["-y", "srv"], "env": {"T": "${T}"}}),
750 false,
751 )
752 .unwrap();
753
754 let config: serde_json::Value =
755 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
756 assert_eq!(config["custom"], true);
757 assert_eq!(config["mcpServers"]["other"]["command"], "x");
758 assert_eq!(config["mcpServers"]["github"]["env"]["T"], "${T}");
759 assert!(has_server(&path, "github").unwrap());
760 assert!(!has_server(&path, "missing").unwrap());
761 }
762
763 #[test]
764 fn register_server_refuses_untracked_collision_unless_overwrite_allowed() {
765 let tmp = tempfile::TempDir::new().unwrap();
766 let path = tmp.path().join("mcp.json");
767 let original = "{\"mcpServers\":{\"github\":{\"command\":\"hand\"}}}";
768 std::fs::write(&path, original).unwrap();
769
770 let error = register_server(
771 &path,
772 "github",
773 serde_json::json!({"command": "npx"}),
774 false,
775 )
776 .unwrap_err()
777 .to_string();
778 assert!(
779 error.contains("refusing to overwrite untracked MCP server"),
780 "{error}"
781 );
782 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
783
784 register_server(&path, "github", serde_json::json!({"command": "npx"}), true).unwrap();
785 let config: serde_json::Value =
786 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
787 assert_eq!(config["mcpServers"]["github"]["command"], "npx");
788 }
789}