1use serde_json::Value;
2
3use super::types::{ConfigType, EditorTarget};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum WriteAction {
7 Created,
8 Updated,
9 Already,
10}
11
12#[derive(Debug, Clone, Copy, Default)]
13pub struct WriteOptions {
14 pub overwrite_invalid: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct WriteResult {
19 pub action: WriteAction,
20 pub note: Option<String>,
21}
22
23pub fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
24 write_config_with_options(target, binary, WriteOptions::default())
25}
26
27pub fn write_config_with_options(
28 target: &EditorTarget,
29 binary: &str,
30 opts: WriteOptions,
31) -> Result<WriteResult, String> {
32 if let Some(parent) = target.config_path.parent() {
33 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
34 }
35
36 match target.config_type {
37 ConfigType::McpJson => write_mcp_json(target, binary, opts),
38 ConfigType::Zed => write_zed_config(target, binary, opts),
39 ConfigType::Codex => write_codex_config(target, binary),
40 ConfigType::VsCodeMcp => write_vscode_mcp(target, binary, opts),
41 ConfigType::OpenCode => write_opencode_config(target, binary, opts),
42 ConfigType::Crush => write_crush_config(target, binary, opts),
43 }
44}
45
46fn auto_approve_tools() -> Vec<&'static str> {
47 vec![
48 "ctx_read",
49 "ctx_shell",
50 "ctx_search",
51 "ctx_tree",
52 "ctx_overview",
53 "ctx_preload",
54 "ctx_compress",
55 "ctx_metrics",
56 "ctx_session",
57 "ctx_knowledge",
58 "ctx_agent",
59 "ctx_share",
60 "ctx_analyze",
61 "ctx_benchmark",
62 "ctx_cache",
63 "ctx_discover",
64 "ctx_smart_read",
65 "ctx_delta",
66 "ctx_edit",
67 "ctx_dedup",
68 "ctx_fill",
69 "ctx_intent",
70 "ctx_response",
71 "ctx_context",
72 "ctx_graph",
73 "ctx_wrapped",
74 "ctx_multi_read",
75 "ctx_semantic_search",
76 "ctx_symbol",
77 "ctx_outline",
78 "ctx_callers",
79 "ctx_callees",
80 "ctx_routes",
81 "ctx_graph_diagram",
82 "ctx_cost",
83 "ctx_heatmap",
84 "ctx_task",
85 "ctx_impact",
86 "ctx_architecture",
87 "ctx_workflow",
88 "ctx",
89 ]
90}
91
92fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
93 let mut entry = serde_json::json!({
94 "command": binary,
95 "env": {
96 "LEAN_CTX_DATA_DIR": data_dir
97 }
98 });
99 if include_auto_approve {
100 entry["autoApprove"] = serde_json::json!(auto_approve_tools());
101 }
102 entry
103}
104
105const NO_AUTO_APPROVE_EDITORS: &[&str] = &["Antigravity"];
106
107fn default_data_dir() -> Result<String, String> {
108 Ok(crate::core::data_dir::lean_ctx_data_dir()?
109 .to_string_lossy()
110 .to_string())
111}
112
113fn write_mcp_json(
114 target: &EditorTarget,
115 binary: &str,
116 opts: WriteOptions,
117) -> Result<WriteResult, String> {
118 let data_dir = default_data_dir()?;
119 let include_aa = !NO_AUTO_APPROVE_EDITORS.contains(&target.name);
120 let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
121
122 if target.agent_key == "claude" || target.name == "Claude Code" {
125 if let Ok(result) = try_claude_mcp_add(&desired) {
126 return Ok(result);
127 }
128 }
129
130 if target.config_path.exists() {
131 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
132 let mut json = match serde_json::from_str::<Value>(&content) {
133 Ok(v) => v,
134 Err(e) => {
135 if !opts.overwrite_invalid {
136 return Err(e.to_string());
137 }
138 backup_invalid_file(&target.config_path)?;
139 return write_mcp_json_fresh(
140 &target.config_path,
141 desired,
142 Some("overwrote invalid JSON".to_string()),
143 );
144 }
145 };
146 let obj = json
147 .as_object_mut()
148 .ok_or_else(|| "root JSON must be an object".to_string())?;
149
150 let servers = obj
151 .entry("mcpServers")
152 .or_insert_with(|| serde_json::json!({}));
153 let servers_obj = servers
154 .as_object_mut()
155 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
156
157 let existing = servers_obj.get("lean-ctx").cloned();
158 if existing.as_ref() == Some(&desired) {
159 return Ok(WriteResult {
160 action: WriteAction::Already,
161 note: None,
162 });
163 }
164 servers_obj.insert("lean-ctx".to_string(), desired);
165
166 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
167 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
168 return Ok(WriteResult {
169 action: WriteAction::Updated,
170 note: None,
171 });
172 }
173
174 write_mcp_json_fresh(&target.config_path, desired, None)
175}
176
177fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
178 use std::io::Write;
179 use std::process::{Command, Stdio};
180
181 let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
182
183 let mut child = Command::new("claude")
184 .args(["mcp", "add-json", "--scope", "user", "lean-ctx"])
185 .stdin(Stdio::piped())
186 .stdout(Stdio::null())
187 .stderr(Stdio::null())
188 .spawn()
189 .map_err(|e| e.to_string())?;
190
191 if let Some(stdin) = child.stdin.as_mut() {
192 stdin
193 .write_all(server_json.as_bytes())
194 .map_err(|e| e.to_string())?;
195 }
196 let status = child.wait().map_err(|e| e.to_string())?;
197
198 if status.success() {
199 Ok(WriteResult {
200 action: WriteAction::Updated,
201 note: Some("via claude mcp add-json".to_string()),
202 })
203 } else {
204 Err("claude mcp add-json failed".to_string())
205 }
206}
207
208fn write_mcp_json_fresh(
209 path: &std::path::Path,
210 desired: Value,
211 note: Option<String>,
212) -> Result<WriteResult, String> {
213 let content = serde_json::to_string_pretty(&serde_json::json!({
214 "mcpServers": { "lean-ctx": desired }
215 }))
216 .map_err(|e| e.to_string())?;
217 crate::config_io::write_atomic_with_backup(path, &content)?;
218 Ok(WriteResult {
219 action: if note.is_some() {
220 WriteAction::Updated
221 } else {
222 WriteAction::Created
223 },
224 note,
225 })
226}
227
228fn write_zed_config(
229 target: &EditorTarget,
230 binary: &str,
231 opts: WriteOptions,
232) -> Result<WriteResult, String> {
233 let desired = serde_json::json!({
234 "source": "custom",
235 "command": binary,
236 "args": [],
237 "env": {}
238 });
239
240 if target.config_path.exists() {
241 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
242 let mut json = match serde_json::from_str::<Value>(&content) {
243 Ok(v) => v,
244 Err(e) => {
245 if !opts.overwrite_invalid {
246 return Err(e.to_string());
247 }
248 backup_invalid_file(&target.config_path)?;
249 return write_zed_config_fresh(
250 &target.config_path,
251 desired,
252 Some("overwrote invalid JSON".to_string()),
253 );
254 }
255 };
256 let obj = json
257 .as_object_mut()
258 .ok_or_else(|| "root JSON must be an object".to_string())?;
259
260 let servers = obj
261 .entry("context_servers")
262 .or_insert_with(|| serde_json::json!({}));
263 let servers_obj = servers
264 .as_object_mut()
265 .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
266
267 let existing = servers_obj.get("lean-ctx").cloned();
268 if existing.as_ref() == Some(&desired) {
269 return Ok(WriteResult {
270 action: WriteAction::Already,
271 note: None,
272 });
273 }
274 servers_obj.insert("lean-ctx".to_string(), desired);
275
276 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
277 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
278 return Ok(WriteResult {
279 action: WriteAction::Updated,
280 note: None,
281 });
282 }
283
284 write_zed_config_fresh(&target.config_path, desired, None)
285}
286
287fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
288 if target.config_path.exists() {
289 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
290 let updated = upsert_codex_toml(&content, binary);
291 if updated == content {
292 return Ok(WriteResult {
293 action: WriteAction::Already,
294 note: None,
295 });
296 }
297 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
298 return Ok(WriteResult {
299 action: WriteAction::Updated,
300 note: None,
301 });
302 }
303
304 let content = format!(
305 "[mcp_servers.lean-ctx]\ncommand = \"{}\"\nargs = []\n",
306 binary
307 );
308 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
309 Ok(WriteResult {
310 action: WriteAction::Created,
311 note: None,
312 })
313}
314
315fn write_zed_config_fresh(
316 path: &std::path::Path,
317 desired: Value,
318 note: Option<String>,
319) -> Result<WriteResult, String> {
320 let content = serde_json::to_string_pretty(&serde_json::json!({
321 "context_servers": { "lean-ctx": desired }
322 }))
323 .map_err(|e| e.to_string())?;
324 crate::config_io::write_atomic_with_backup(path, &content)?;
325 Ok(WriteResult {
326 action: if note.is_some() {
327 WriteAction::Updated
328 } else {
329 WriteAction::Created
330 },
331 note,
332 })
333}
334
335fn write_vscode_mcp(
336 target: &EditorTarget,
337 binary: &str,
338 opts: WriteOptions,
339) -> Result<WriteResult, String> {
340 let desired = serde_json::json!({ "command": binary, "args": [] });
341
342 if target.config_path.exists() {
343 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
344 let mut json = match serde_json::from_str::<Value>(&content) {
345 Ok(v) => v,
346 Err(e) => {
347 if !opts.overwrite_invalid {
348 return Err(e.to_string());
349 }
350 backup_invalid_file(&target.config_path)?;
351 return write_vscode_mcp_fresh(
352 &target.config_path,
353 binary,
354 Some("overwrote invalid JSON".to_string()),
355 );
356 }
357 };
358 let obj = json
359 .as_object_mut()
360 .ok_or_else(|| "root JSON must be an object".to_string())?;
361
362 let servers = obj
363 .entry("servers")
364 .or_insert_with(|| serde_json::json!({}));
365 let servers_obj = servers
366 .as_object_mut()
367 .ok_or_else(|| "\"servers\" must be an object".to_string())?;
368
369 let existing = servers_obj.get("lean-ctx").cloned();
370 if existing.as_ref() == Some(&desired) {
371 return Ok(WriteResult {
372 action: WriteAction::Already,
373 note: None,
374 });
375 }
376 servers_obj.insert("lean-ctx".to_string(), desired);
377
378 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
379 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
380 return Ok(WriteResult {
381 action: WriteAction::Updated,
382 note: None,
383 });
384 }
385
386 write_vscode_mcp_fresh(&target.config_path, binary, None)
387}
388
389fn write_vscode_mcp_fresh(
390 path: &std::path::Path,
391 binary: &str,
392 note: Option<String>,
393) -> Result<WriteResult, String> {
394 let content = serde_json::to_string_pretty(&serde_json::json!({
395 "servers": { "lean-ctx": { "command": binary, "args": [] } }
396 }))
397 .map_err(|e| e.to_string())?;
398 crate::config_io::write_atomic_with_backup(path, &content)?;
399 Ok(WriteResult {
400 action: if note.is_some() {
401 WriteAction::Updated
402 } else {
403 WriteAction::Created
404 },
405 note,
406 })
407}
408
409fn write_opencode_config(
410 target: &EditorTarget,
411 binary: &str,
412 opts: WriteOptions,
413) -> Result<WriteResult, String> {
414 let desired = serde_json::json!({
415 "type": "local",
416 "command": [binary],
417 "enabled": true
418 });
419
420 if target.config_path.exists() {
421 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
422 let mut json = match serde_json::from_str::<Value>(&content) {
423 Ok(v) => v,
424 Err(e) => {
425 if !opts.overwrite_invalid {
426 return Err(e.to_string());
427 }
428 backup_invalid_file(&target.config_path)?;
429 return write_opencode_fresh(
430 &target.config_path,
431 binary,
432 Some("overwrote invalid JSON".to_string()),
433 );
434 }
435 };
436 let obj = json
437 .as_object_mut()
438 .ok_or_else(|| "root JSON must be an object".to_string())?;
439 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
440 let mcp_obj = mcp
441 .as_object_mut()
442 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
443
444 let existing = mcp_obj.get("lean-ctx").cloned();
445 if existing.as_ref() == Some(&desired) {
446 return Ok(WriteResult {
447 action: WriteAction::Already,
448 note: None,
449 });
450 }
451 mcp_obj.insert("lean-ctx".to_string(), desired);
452
453 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
454 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
455 return Ok(WriteResult {
456 action: WriteAction::Updated,
457 note: None,
458 });
459 }
460
461 write_opencode_fresh(&target.config_path, binary, None)
462}
463
464fn write_opencode_fresh(
465 path: &std::path::Path,
466 binary: &str,
467 note: Option<String>,
468) -> Result<WriteResult, String> {
469 let content = serde_json::to_string_pretty(&serde_json::json!({
470 "$schema": "https://opencode.ai/config.json",
471 "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true } }
472 }))
473 .map_err(|e| e.to_string())?;
474 crate::config_io::write_atomic_with_backup(path, &content)?;
475 Ok(WriteResult {
476 action: if note.is_some() {
477 WriteAction::Updated
478 } else {
479 WriteAction::Created
480 },
481 note,
482 })
483}
484
485fn write_crush_config(
486 target: &EditorTarget,
487 binary: &str,
488 opts: WriteOptions,
489) -> Result<WriteResult, String> {
490 let desired = serde_json::json!({ "type": "stdio", "command": binary });
491
492 if target.config_path.exists() {
493 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
494 let mut json = match serde_json::from_str::<Value>(&content) {
495 Ok(v) => v,
496 Err(e) => {
497 if !opts.overwrite_invalid {
498 return Err(e.to_string());
499 }
500 backup_invalid_file(&target.config_path)?;
501 return write_crush_fresh(
502 &target.config_path,
503 desired,
504 Some("overwrote invalid JSON".to_string()),
505 );
506 }
507 };
508 let obj = json
509 .as_object_mut()
510 .ok_or_else(|| "root JSON must be an object".to_string())?;
511 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
512 let mcp_obj = mcp
513 .as_object_mut()
514 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
515
516 let existing = mcp_obj.get("lean-ctx").cloned();
517 if existing.as_ref() == Some(&desired) {
518 return Ok(WriteResult {
519 action: WriteAction::Already,
520 note: None,
521 });
522 }
523 mcp_obj.insert("lean-ctx".to_string(), desired);
524
525 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
526 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
527 return Ok(WriteResult {
528 action: WriteAction::Updated,
529 note: None,
530 });
531 }
532
533 write_crush_fresh(&target.config_path, desired, None)
534}
535
536fn write_crush_fresh(
537 path: &std::path::Path,
538 desired: Value,
539 note: Option<String>,
540) -> Result<WriteResult, String> {
541 let content = serde_json::to_string_pretty(&serde_json::json!({
542 "mcp": { "lean-ctx": desired }
543 }))
544 .map_err(|e| e.to_string())?;
545 crate::config_io::write_atomic_with_backup(path, &content)?;
546 Ok(WriteResult {
547 action: if note.is_some() {
548 WriteAction::Updated
549 } else {
550 WriteAction::Created
551 },
552 note,
553 })
554}
555
556fn upsert_codex_toml(existing: &str, binary: &str) -> String {
557 let mut out = String::with_capacity(existing.len() + 128);
558 let mut in_section = false;
559 let mut saw_section = false;
560 let mut wrote_command = false;
561 let mut wrote_args = false;
562
563 for line in existing.lines() {
564 let trimmed = line.trim();
565 if trimmed.starts_with('[') && trimmed.ends_with(']') {
566 if in_section && !wrote_command {
567 out.push_str(&format!("command = \"{}\"\n", binary));
568 wrote_command = true;
569 }
570 if in_section && !wrote_args {
571 out.push_str("args = []\n");
572 wrote_args = true;
573 }
574 in_section = trimmed == "[mcp_servers.lean-ctx]";
575 if in_section {
576 saw_section = true;
577 }
578 out.push_str(line);
579 out.push('\n');
580 continue;
581 }
582
583 if in_section {
584 if trimmed.starts_with("command") && trimmed.contains('=') {
585 out.push_str(&format!("command = \"{}\"\n", binary));
586 wrote_command = true;
587 continue;
588 }
589 if trimmed.starts_with("args") && trimmed.contains('=') {
590 out.push_str("args = []\n");
591 wrote_args = true;
592 continue;
593 }
594 }
595
596 out.push_str(line);
597 out.push('\n');
598 }
599
600 if saw_section {
601 if in_section && !wrote_command {
602 out.push_str(&format!("command = \"{}\"\n", binary));
603 }
604 if in_section && !wrote_args {
605 out.push_str("args = []\n");
606 }
607 return out;
608 }
609
610 if !out.ends_with('\n') {
611 out.push('\n');
612 }
613 out.push_str("\n[mcp_servers.lean-ctx]\n");
614 out.push_str(&format!("command = \"{}\"\n", binary));
615 out.push_str("args = []\n");
616 out
617}
618
619fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
620 if !path.exists() {
621 return Ok(());
622 }
623 let parent = path
624 .parent()
625 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
626 let filename = path
627 .file_name()
628 .ok_or_else(|| "invalid path (no filename)".to_string())?
629 .to_string_lossy();
630 let pid = std::process::id();
631 let nanos = std::time::SystemTime::now()
632 .duration_since(std::time::UNIX_EPOCH)
633 .map(|d| d.as_nanos())
634 .unwrap_or(0);
635 let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
636 std::fs::rename(path, bak).map_err(|e| e.to_string())?;
637 Ok(())
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use std::path::PathBuf;
644
645 fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
646 EditorTarget {
647 name: "test",
648 agent_key: "test".to_string(),
649 config_path: path,
650 detect_path: PathBuf::from("/nonexistent"),
651 config_type: ty,
652 }
653 }
654
655 #[test]
656 fn mcp_json_upserts_and_preserves_other_servers() {
657 let dir = tempfile::tempdir().unwrap();
658 let path = dir.path().join("mcp.json");
659 std::fs::write(
660 &path,
661 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
662 )
663 .unwrap();
664
665 let t = target(path.clone(), ConfigType::McpJson);
666 let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
667 assert_eq!(res.action, WriteAction::Updated);
668
669 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
670 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
671 assert_eq!(
672 json["mcpServers"]["lean-ctx"]["command"],
673 "/new/path/lean-ctx"
674 );
675 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
676 assert!(
677 json["mcpServers"]["lean-ctx"]["autoApprove"]
678 .as_array()
679 .unwrap()
680 .len()
681 > 5
682 );
683 }
684
685 #[test]
686 fn crush_config_writes_mcp_root() {
687 let dir = tempfile::tempdir().unwrap();
688 let path = dir.path().join("crush.json");
689 std::fs::write(
690 &path,
691 r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
692 )
693 .unwrap();
694
695 let t = target(path.clone(), ConfigType::Crush);
696 let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
697 assert_eq!(res.action, WriteAction::Updated);
698
699 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
700 assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
701 assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
702 }
703
704 #[test]
705 fn codex_toml_upserts_existing_section() {
706 let dir = tempfile::tempdir().unwrap();
707 let path = dir.path().join("config.toml");
708 std::fs::write(
709 &path,
710 r#"[mcp_servers.lean-ctx]
711command = "old"
712args = ["x"]
713"#,
714 )
715 .unwrap();
716
717 let t = target(path.clone(), ConfigType::Codex);
718 let res = write_codex_config(&t, "new").unwrap();
719 assert_eq!(res.action, WriteAction::Updated);
720
721 let content = std::fs::read_to_string(&path).unwrap();
722 assert!(content.contains(r#"command = "new""#));
723 assert!(content.contains("args = []"));
724 }
725
726 #[test]
727 fn upsert_codex_toml_inserts_new_section_when_missing() {
728 let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
729 assert!(updated.contains("[mcp_servers.lean-ctx]"));
730 assert!(updated.contains("command = \"lean-ctx\""));
731 assert!(updated.contains("args = []"));
732 }
733
734 #[test]
735 fn auto_approve_contains_core_tools() {
736 let tools = auto_approve_tools();
737 assert!(tools.contains(&"ctx_read"));
738 assert!(tools.contains(&"ctx_shell"));
739 assert!(tools.contains(&"ctx_search"));
740 assert!(tools.contains(&"ctx_workflow"));
741 assert!(tools.contains(&"ctx_cost"));
742 }
743
744 #[test]
745 fn antigravity_config_omits_auto_approve() {
746 let dir = tempfile::tempdir().unwrap();
747 let path = dir.path().join("mcp_config.json");
748
749 let t = EditorTarget {
750 name: "Antigravity",
751 agent_key: "gemini".to_string(),
752 config_path: path.clone(),
753 detect_path: PathBuf::from("/nonexistent"),
754 config_type: ConfigType::McpJson,
755 };
756 let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
757 assert_eq!(res.action, WriteAction::Created);
758
759 let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
760 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
761 assert_eq!(
762 json["mcpServers"]["lean-ctx"]["command"],
763 "/usr/local/bin/lean-ctx"
764 );
765 }
766}