1use crate::error::MurkError;
14use std::path::{Path, PathBuf};
15
16pub(crate) struct ServerSpec {
18 pub extra_args: Vec<String>,
20 pub key_file: String,
22 pub typed_stdio: bool,
24}
25
26fn quote(s: &str) -> String {
29 serde_json::to_string(s).unwrap_or_else(|_| format!("{s:?}"))
30}
31
32fn render_value(spec: &ServerSpec, member_indent: &str, unit: &str) -> String {
35 use std::fmt::Write as _;
36 let i1 = format!("{member_indent}{unit}");
37 let i2 = format!("{i1}{unit}");
38 let mut args = vec![quote("mcp")];
39 args.extend(spec.extra_args.iter().map(|a| quote(a)));
40
41 let mut out = String::from("{\n");
42 if spec.typed_stdio {
43 let _ = writeln!(out, "{i1}\"type\": \"stdio\",");
44 }
45 let _ = writeln!(out, "{i1}\"command\": \"murk\",");
46 let _ = writeln!(out, "{i1}\"args\": [{}],", args.join(", "));
47 let _ = writeln!(out, "{i1}\"env\": {{");
48 let _ = writeln!(out, "{i2}\"MURK_KEY_FILE\": {},", quote(&spec.key_file));
49 let _ = writeln!(out, "{i2}\"MURK_AGENT\": \"1\"");
50 let _ = writeln!(out, "{i1}}}");
51 let _ = write!(out, "{member_indent}}}");
52 out
53}
54
55fn render_member(name: &str, spec: &ServerSpec, member_indent: &str, unit: &str) -> String {
57 format!(
58 "{member_indent}{}: {}",
59 quote(name),
60 render_value(spec, member_indent, unit)
61 )
62}
63
64fn skip_trivia(b: &[u8], mut i: usize) -> usize {
69 loop {
70 while i < b.len() && b[i].is_ascii_whitespace() {
71 i += 1;
72 }
73 if i + 1 < b.len() && b[i] == b'/' && b[i + 1] == b'/' {
74 i += 2;
75 while i < b.len() && b[i] != b'\n' {
76 i += 1;
77 }
78 } else if i + 1 < b.len() && b[i] == b'/' && b[i + 1] == b'*' {
79 i += 2;
80 while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') {
81 i += 1;
82 }
83 i = (i + 2).min(b.len());
84 } else {
85 return i;
86 }
87 }
88}
89
90fn scan_string_end(b: &[u8], i: usize) -> Option<usize> {
92 let mut j = i + 1;
93 while j < b.len() {
94 match b[j] {
95 b'\\' => j += 2,
96 b'"' => return Some(j + 1),
97 _ => j += 1,
98 }
99 }
100 None
101}
102
103fn scan_bracketed_end(b: &[u8], i: usize) -> Option<usize> {
105 let open = b[i];
106 let close = if open == b'{' { b'}' } else { b']' };
107 let mut depth = 0i32;
108 let mut j = i;
109 while j < b.len() {
110 match b[j] {
111 b'"' => {
112 j = scan_string_end(b, j)?;
113 }
114 b'/' if j + 1 < b.len() && (b[j + 1] == b'/' || b[j + 1] == b'*') => {
115 j = skip_trivia(b, j);
116 }
117 c if c == open => {
118 depth += 1;
119 j += 1;
120 }
121 c if c == close => {
122 depth -= 1;
123 j += 1;
124 if depth == 0 {
125 return Some(j);
126 }
127 }
128 _ => j += 1,
129 }
130 }
131 None
132}
133
134fn scan_value_end(b: &[u8], i: usize) -> Option<usize> {
136 let i = skip_trivia(b, i);
137 if i >= b.len() {
138 return None;
139 }
140 match b[i] {
141 b'"' => scan_string_end(b, i),
142 b'{' | b'[' => scan_bracketed_end(b, i),
143 _ => {
144 let mut j = i;
145 while j < b.len()
146 && !matches!(b[j], b',' | b'}' | b']')
147 && !b[j].is_ascii_whitespace()
148 && !(b[j] == b'/' && j + 1 < b.len() && (b[j + 1] == b'/' || b[j + 1] == b'*'))
149 {
150 j += 1;
151 }
152 (j > i).then_some(j)
153 }
154 }
155}
156
157struct Member {
159 key: String,
160 key_start: usize,
161 value_end: usize,
162}
163
164fn object_members(b: &[u8], open: usize) -> Option<(usize, Vec<Member>)> {
167 let mut i = open + 1;
168 let mut members = Vec::new();
169 loop {
170 i = skip_trivia(b, i);
171 if i >= b.len() {
172 return None;
173 }
174 if b[i] == b'}' {
175 return Some((i, members));
176 }
177 if b[i] != b'"' {
178 return None;
179 }
180 let key_start = i;
181 let key_end = scan_string_end(b, i)?;
182 let key = std::str::from_utf8(b.get(key_start + 1..key_end - 1)?)
183 .ok()?
184 .to_string();
185 i = skip_trivia(b, key_end);
186 if i >= b.len() || b[i] != b':' {
187 return None;
188 }
189 let value_end = scan_value_end(b, i + 1)?;
190 members.push(Member {
191 key,
192 key_start,
193 value_end,
194 });
195 i = skip_trivia(b, value_end);
196 if i < b.len() && b[i] == b',' {
197 i += 1;
198 }
199 }
200}
201
202fn top_object_open(b: &[u8]) -> Option<usize> {
204 let i = skip_trivia(b, 0);
205 (i < b.len() && b[i] == b'{').then_some(i)
206}
207
208fn detect_indent(text: &str) -> String {
211 for line in text.lines() {
212 let ws: String = line
213 .chars()
214 .take_while(|c| *c == ' ' || *c == '\t')
215 .collect();
216 if !ws.is_empty() && ws.len() < line.len() {
217 return ws;
218 }
219 }
220 " ".to_string()
221}
222
223fn line_indent(b: &[u8], idx: usize) -> String {
225 let line_start = b[..idx]
226 .iter()
227 .rposition(|&c| c == b'\n')
228 .map_or(0, |p| p + 1);
229 let ws_end = b[line_start..idx]
230 .iter()
231 .position(|&c| c != b' ' && c != b'\t')
232 .map_or(idx - line_start, |p| p);
233 String::from_utf8_lossy(&b[line_start..line_start + ws_end]).into_owned()
234}
235
236fn fresh_document(root_key: &str, server: &str, spec: &ServerSpec, unit: &str) -> String {
238 let member = render_member(server, spec, &unit.repeat(2), unit);
239 format!(
240 "{{\n{unit}{}: {{\n{member}\n{unit}}}\n}}\n",
241 quote(root_key)
242 )
243}
244
245fn insert_first_member(
248 text: &str,
249 obj_open: usize,
250 close: usize,
251 empty: bool,
252 server: &str,
253 spec: &ServerSpec,
254 member_indent: &str,
255 unit: &str,
256) -> String {
257 let member = render_member(server, spec, member_indent, unit);
258 let b = text.as_bytes();
259 let after_open = obj_open + 1;
260 let insertion = if empty {
261 let close_indent = line_indent(b, obj_open);
263 format!("\n{member}\n{close_indent}")
264 } else {
265 format!("\n{member},")
266 };
267 let mut out = String::with_capacity(text.len() + insertion.len());
268 out.push_str(&text[..after_open]);
269 out.push_str(&insertion);
270 if empty {
271 out.push_str(&text[close..]);
273 } else {
274 out.push_str(&text[after_open..]);
275 }
276 out
277}
278
279pub(crate) fn upsert_json_server(
283 existing: Option<&str>,
284 root_key: &str,
285 server: &str,
286 spec: &ServerSpec,
287) -> Result<String, MurkError> {
288 let text = existing.unwrap_or("");
289 if text.trim().is_empty() {
290 return Ok(fresh_document(root_key, server, spec, " "));
291 }
292 let unit = detect_indent(text);
293 let b = text.as_bytes();
294 let top = top_object_open(b)
295 .ok_or_else(|| MurkError::Io(std::io::Error::other("config is not a JSON object")))?;
296 let (_top_close, top_members) = object_members(b, top)
297 .ok_or_else(|| MurkError::Io(std::io::Error::other("could not parse config JSON")))?;
298
299 let Some(root_member) = top_members.iter().find(|m| m.key == root_key) else {
301 let member_indent = line_indent(b, top_members.first().map_or(top, |m| m.key_start));
303 let inner = unit.repeat(2);
304 let server_member = render_member(server, spec, &inner, &unit);
305 let container = format!(
306 "{}: {{\n{server_member}\n{member_indent}}}",
307 quote(root_key)
308 );
309 let (top_close, _) = object_members(b, top).unwrap();
310 return Ok(splice_top_member(
311 text,
312 top,
313 top_close,
314 top_members.is_empty(),
315 &container,
316 &member_indent,
317 ));
318 };
319
320 let colon = find_colon_after_key(b, root_member.key_start)
323 .ok_or_else(|| MurkError::Io(std::io::Error::other("malformed config member")))?;
324 let cval_start = skip_trivia(b, colon + 1);
325 if cval_start >= b.len() || b[cval_start] != b'{' {
326 return Err(MurkError::Io(std::io::Error::other(format!(
327 "\"{root_key}\" is not an object in this config"
328 ))));
329 }
330 let (cclose, cmembers) = object_members(b, cval_start)
331 .ok_or_else(|| MurkError::Io(std::io::Error::other("could not parse server list")))?;
332
333 if let Some(existing_server) = cmembers.iter().find(|m| m.key == server) {
334 let member_indent = line_indent(b, existing_server.key_start);
336 let value = render_value(spec, &member_indent, &unit);
337 let vstart = find_colon_after_key(b, existing_server.key_start)
338 .map(|c| skip_trivia(b, c + 1))
339 .ok_or_else(|| MurkError::Io(std::io::Error::other("malformed murk entry")))?;
340 let mut out = String::with_capacity(text.len());
341 out.push_str(&text[..vstart]);
342 out.push_str(&value);
343 out.push_str(&text[existing_server.value_end..]);
344 return Ok(out);
345 }
346
347 let member_indent = if let Some(first) = cmembers.first() {
349 line_indent(b, first.key_start)
350 } else {
351 format!("{}{}", line_indent(b, cval_start), unit)
352 };
353 Ok(insert_first_member(
354 text,
355 cval_start,
356 cclose,
357 cmembers.is_empty(),
358 server,
359 spec,
360 &member_indent,
361 &unit,
362 ))
363}
364
365fn splice_top_member(
368 text: &str,
369 top_open: usize,
370 top_close: usize,
371 empty: bool,
372 member: &str,
373 member_indent: &str,
374) -> String {
375 let after = top_open + 1;
376 let insertion = if empty {
377 let close_indent = line_indent(text.as_bytes(), top_open);
378 format!("\n{member_indent}{member}\n{close_indent}")
379 } else {
380 format!("\n{member_indent}{member},")
381 };
382 let mut out = String::with_capacity(text.len() + insertion.len());
383 out.push_str(&text[..after]);
384 out.push_str(&insertion);
385 if empty {
386 out.push_str(&text[top_close..]);
387 } else {
388 out.push_str(&text[after..]);
389 }
390 out
391}
392
393fn find_colon_after_key(b: &[u8], key_start: usize) -> Option<usize> {
395 let key_end = scan_string_end(b, key_start)?;
396 let i = skip_trivia(b, key_end);
397 (i < b.len() && b[i] == b':').then_some(i)
398}
399
400#[allow(clippy::unnecessary_wraps)]
405pub(crate) fn remove_json_server(
406 existing: &str,
407 root_key: &str,
408 server: &str,
409) -> Result<Option<String>, MurkError> {
410 let b = existing.as_bytes();
411 let Some(top) = top_object_open(b) else {
412 return Ok(None);
413 };
414 let Some((_, top_members)) = object_members(b, top) else {
415 return Ok(None);
416 };
417 let Some(root_member) = top_members.iter().find(|m| m.key == root_key) else {
418 return Ok(None);
419 };
420 let Some(colon) = find_colon_after_key(b, root_member.key_start) else {
421 return Ok(None);
422 };
423 let cval_start = skip_trivia(b, colon + 1);
424 if cval_start >= b.len() || b[cval_start] != b'{' {
425 return Ok(None);
426 }
427 let Some((_, cmembers)) = object_members(b, cval_start) else {
428 return Ok(None);
429 };
430 let Some(idx) = cmembers.iter().position(|m| m.key == server) else {
431 return Ok(None);
432 };
433 let target = &cmembers[idx];
434
435 let line_start = b[..target.key_start]
439 .iter()
440 .rposition(|&c| c == b'\n')
441 .map_or(0, |p| p + 1);
442 let mut del_start = line_start;
443 let mut del_end = target.value_end;
444
445 let after = skip_trivia(b, target.value_end);
446 if after < b.len() && b[after] == b',' {
447 del_end = after + 1;
449 } else {
450 if let Some(prev_comma) = b[..line_start]
452 .iter()
453 .rposition(|&c| !c.is_ascii_whitespace())
454 && b[prev_comma] == b','
455 {
456 del_start = prev_comma;
457 }
458 }
459 if del_end < b.len() && b[del_end] == b'\n' {
461 del_end += 1;
462 }
463
464 let mut out = String::with_capacity(existing.len());
465 out.push_str(&existing[..del_start]);
466 out.push_str(&existing[del_end..]);
467 Ok(Some(out))
468}
469
470#[derive(Debug, PartialEq, Eq, Clone, Copy)]
474pub enum ConfigFormat {
475 Json,
478 Toml,
480}
481
482pub struct ClientAdapter {
487 pub id: &'static str,
488 pub display: &'static str,
489 pub rel_path: &'static str,
491 pub marker_dir: &'static str,
493 pub root_key: &'static str,
497 pub typed_stdio: bool,
499 pub format: ConfigFormat,
501}
502
503pub const ADAPTERS: &[ClientAdapter] = &[
506 ClientAdapter {
507 id: "claude",
508 display: "Claude Code",
509 rel_path: ".mcp.json",
510 marker_dir: ".claude",
511 root_key: "mcpServers",
512 typed_stdio: false,
513 format: ConfigFormat::Json,
514 },
515 ClientAdapter {
516 id: "cursor",
517 display: "Cursor",
518 rel_path: ".cursor/mcp.json",
519 marker_dir: ".cursor",
520 root_key: "mcpServers",
521 typed_stdio: false,
522 format: ConfigFormat::Json,
523 },
524 ClientAdapter {
525 id: "vscode",
526 display: "VS Code",
527 rel_path: ".vscode/mcp.json",
528 marker_dir: ".vscode",
529 root_key: "servers",
530 typed_stdio: true,
531 format: ConfigFormat::Json,
532 },
533 ClientAdapter {
534 id: "zed",
535 display: "Zed",
536 rel_path: ".zed/settings.json",
537 marker_dir: ".zed",
538 root_key: "context_servers",
539 typed_stdio: false,
540 format: ConfigFormat::Json,
541 },
542 ClientAdapter {
543 id: "gemini",
544 display: "Gemini CLI",
545 rel_path: ".gemini/settings.json",
546 marker_dir: ".gemini",
547 root_key: "mcpServers",
548 typed_stdio: false,
549 format: ConfigFormat::Json,
550 },
551 ClientAdapter {
552 id: "omp",
553 display: "omp",
554 rel_path: ".omp/mcp.json",
555 marker_dir: ".omp",
556 root_key: "mcpServers",
557 typed_stdio: false,
558 format: ConfigFormat::Json,
559 },
560 ClientAdapter {
561 id: "codex",
562 display: "Codex",
563 rel_path: ".codex/config.toml",
564 marker_dir: ".codex",
565 root_key: "mcp_servers",
566 typed_stdio: false,
567 format: ConfigFormat::Toml,
568 },
569];
570
571pub fn adapter(id: &str) -> Option<&'static ClientAdapter> {
573 ADAPTERS.iter().find(|a| a.id.eq_ignore_ascii_case(id))
574}
575
576pub fn detect(project_dir: &Path) -> Vec<&'static ClientAdapter> {
579 ADAPTERS
580 .iter()
581 .filter(|a| {
582 project_dir.join(a.rel_path).exists() || project_dir.join(a.marker_dir).is_dir()
583 })
584 .collect()
585}
586
587impl ClientAdapter {
588 pub(crate) fn config_path(&self, project_dir: &Path) -> PathBuf {
590 project_dir.join(self.rel_path)
591 }
592}
593
594pub struct ConnectOutcome {
596 pub path: PathBuf,
597 pub created: bool,
599}
600
601pub fn connect_client(
605 adapter: &ClientAdapter,
606 project_dir: &Path,
607 key_file: &str,
608 extra_args: &[String],
609) -> Result<ConnectOutcome, MurkError> {
610 let path = adapter.config_path(project_dir);
611 let existing = match std::fs::read_to_string(&path) {
612 Ok(s) => Some(s),
613 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
614 Err(e) => return Err(MurkError::Io(e)),
615 };
616 let created = existing.is_none();
617 let spec = ServerSpec {
618 extra_args: extra_args.to_vec(),
619 key_file: key_file.to_string(),
620 typed_stdio: adapter.typed_stdio,
621 };
622 let updated = match adapter.format {
623 ConfigFormat::Json => {
624 upsert_json_server(existing.as_deref(), adapter.root_key, "murk", &spec)?
625 }
626 ConfigFormat::Toml => {
627 upsert_toml_server(existing.as_deref(), adapter.root_key, "murk", &spec)?
628 }
629 };
630 write_config(&path, &updated)?;
631 Ok(ConnectOutcome { path, created })
632}
633
634pub fn disconnect_client(
637 adapter: &ClientAdapter,
638 project_dir: &Path,
639) -> Result<Option<PathBuf>, MurkError> {
640 let path = adapter.config_path(project_dir);
641 let existing = match std::fs::read_to_string(&path) {
642 Ok(s) => s,
643 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
644 Err(e) => return Err(MurkError::Io(e)),
645 };
646 let removed = match adapter.format {
647 ConfigFormat::Json => remove_json_server(&existing, adapter.root_key, "murk")?,
648 ConfigFormat::Toml => remove_toml_server(&existing, adapter.root_key, "murk")?,
649 };
650 match removed {
651 Some(updated) => {
652 write_config(&path, &updated)?;
653 Ok(Some(path))
654 }
655 None => Ok(None),
656 }
657}
658
659fn write_config(path: &Path, contents: &str) -> Result<(), MurkError> {
662 use std::io::Write;
663 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
664 std::fs::create_dir_all(parent).map_err(MurkError::Io)?;
665 }
666 let dir = path
667 .parent()
668 .filter(|p| !p.as_os_str().is_empty())
669 .unwrap_or_else(|| Path::new("."));
670 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(MurkError::Io)?;
671 tmp.write_all(contents.as_bytes()).map_err(MurkError::Io)?;
672 tmp.as_file().sync_all().map_err(MurkError::Io)?;
673 tmp.persist(path).map_err(|e| MurkError::Io(e.error))?;
674 Ok(())
675}
676
677fn toml_table_path(line: &str) -> Option<String> {
688 let t = line.trim_start().strip_prefix('[')?;
689 let t = t.strip_prefix('[').unwrap_or(t);
690 let end = t.find(']')?;
691 Some(t[..end].trim().to_string())
692}
693
694fn murk_toml_sections(lines: &[&str], prefix: &str, server: &str) -> Vec<(usize, usize)> {
697 let exact = format!("{prefix}.{server}");
698 let nested = format!("{prefix}.{server}.");
699 let owned = |p: &str| p == exact || p.starts_with(&nested);
700 let mut ranges = Vec::new();
701 let mut i = 0;
702 while i < lines.len() {
703 match toml_table_path(lines[i]) {
704 Some(p) if owned(&p) => {
705 let start = i;
706 i += 1;
707 while i < lines.len() {
708 match toml_table_path(lines[i]) {
709 Some(q) if !owned(&q) => break,
710 _ => i += 1,
711 }
712 }
713 ranges.push((start, i));
714 }
715 _ => i += 1,
716 }
717 }
718 ranges
719}
720
721fn render_toml_member(prefix: &str, server: &str, spec: &ServerSpec) -> String {
723 use std::fmt::Write as _;
724 let mut args = vec![quote("mcp")];
725 args.extend(spec.extra_args.iter().map(|a| quote(a)));
726 let mut out = String::new();
727 let _ = writeln!(out, "[{prefix}.{server}]");
728 let _ = writeln!(out, "command = {}", quote("murk"));
729 let _ = writeln!(out, "args = [{}]", args.join(", "));
730 let _ = writeln!(out);
731 let _ = writeln!(out, "[{prefix}.{server}.env]");
732 let _ = writeln!(out, "MURK_KEY_FILE = {}", quote(&spec.key_file));
733 let _ = writeln!(out, "MURK_AGENT = {}", quote("1"));
734 out
735}
736
737#[allow(clippy::unnecessary_wraps)]
740pub(crate) fn remove_toml_server(
741 existing: &str,
742 prefix: &str,
743 server: &str,
744) -> Result<Option<String>, MurkError> {
745 let lines: Vec<&str> = existing.lines().collect();
746 let sections = murk_toml_sections(&lines, prefix, server);
747 if sections.is_empty() {
748 return Ok(None);
749 }
750 let drop: std::collections::HashSet<usize> =
751 sections.iter().flat_map(|(s, e)| *s..*e).collect();
752 let mut kept: Vec<&str> = lines
753 .iter()
754 .enumerate()
755 .filter(|(i, _)| !drop.contains(i))
756 .map(|(_, l)| *l)
757 .collect();
758 while kept.last().is_some_and(|l| l.trim().is_empty()) {
759 kept.pop();
760 }
761 let mut out = kept.join("\n");
762 if !out.is_empty() {
763 out.push('\n');
764 }
765 Ok(Some(out))
766}
767
768pub(crate) fn upsert_toml_server(
771 existing: Option<&str>,
772 prefix: &str,
773 server: &str,
774 spec: &ServerSpec,
775) -> Result<String, MurkError> {
776 let base = match existing {
777 Some(s) if !s.trim().is_empty() => {
778 if let Some(stripped) = remove_toml_server(s, prefix, server)? {
779 stripped
780 } else {
781 let mut t = s.trim_end().to_string();
782 t.push('\n');
783 t
784 }
785 }
786 _ => String::new(),
787 };
788 let block = render_toml_member(prefix, server, spec);
789 if base.trim().is_empty() {
790 return Ok(block);
791 }
792 Ok(format!("{base}\n{block}"))
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798
799 fn spec() -> ServerSpec {
800 ServerSpec {
801 extra_args: vec![],
802 key_file: "/home/alice/.config/murk/agent-keys/abc-cursor".into(),
803 typed_stdio: false,
804 }
805 }
806
807 fn strip_comments(s: &str) -> String {
810 let b = s.as_bytes();
811 let mut out = Vec::with_capacity(b.len());
812 let mut i = 0;
813 while i < b.len() {
814 if b[i] == b'"' {
815 out.push(b[i]);
816 i += 1;
817 while i < b.len() {
818 out.push(b[i]);
819 if b[i] == b'\\' && i + 1 < b.len() {
820 out.push(b[i + 1]);
821 i += 2;
822 continue;
823 }
824 if b[i] == b'"' {
825 i += 1;
826 break;
827 }
828 i += 1;
829 }
830 } else if b[i] == b'/' && i + 1 < b.len() && b[i + 1] == b'/' {
831 while i < b.len() && b[i] != b'\n' {
832 i += 1;
833 }
834 } else if b[i] == b'/' && i + 1 < b.len() && b[i + 1] == b'*' {
835 i += 2;
836 while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') {
837 i += 1;
838 }
839 i += 2;
840 } else {
841 out.push(b[i]);
842 i += 1;
843 }
844 }
845 String::from_utf8(out).unwrap()
846 }
847
848 fn parse(s: &str) -> serde_json::Value {
850 serde_json::from_str(&strip_comments(s)).expect("output must be valid JSON(C)")
851 }
852
853 #[test]
854 fn fresh_creates_valid_document() {
855 let out = upsert_json_server(None, "mcpServers", "murk", &spec()).unwrap();
856 let v = parse(&out);
857 assert_eq!(v["mcpServers"]["murk"]["command"], "murk");
858 assert_eq!(v["mcpServers"]["murk"]["args"][0], "mcp");
859 assert_eq!(v["mcpServers"]["murk"]["env"]["MURK_AGENT"], "1");
860 assert_eq!(
861 v["mcpServers"]["murk"]["env"]["MURK_KEY_FILE"],
862 "/home/alice/.config/murk/agent-keys/abc-cursor"
863 );
864 }
865
866 #[test]
867 fn merge_preserves_other_servers_and_comments() {
868 let existing = "{\n // my servers\n \"mcpServers\": {\n \"other\": { \"command\": \"foo\" }\n }\n}\n";
869 let out = upsert_json_server(Some(existing), "mcpServers", "murk", &spec()).unwrap();
870 assert!(out.contains("// my servers"), "comment must survive");
871 let v = parse(&out);
872 assert_eq!(v["mcpServers"]["other"]["command"], "foo");
873 assert_eq!(v["mcpServers"]["murk"]["command"], "murk");
874 }
875
876 #[test]
877 fn vscode_servers_root_and_typed_stdio() {
878 let mut s = spec();
879 s.typed_stdio = true;
880 let out = upsert_json_server(None, "servers", "murk", &s).unwrap();
881 let v = parse(&out);
882 assert_eq!(v["servers"]["murk"]["type"], "stdio");
883 }
884
885 #[test]
886 fn idempotent_update_replaces_value() {
887 let out1 = upsert_json_server(None, "mcpServers", "murk", &spec()).unwrap();
888 let mut s2 = spec();
889 s2.extra_args = vec!["--allow-exec".into()];
890 let out2 = upsert_json_server(Some(&out1), "mcpServers", "murk", &s2).unwrap();
891 let v = parse(&out2);
892 assert_eq!(v["mcpServers"]["murk"]["args"][1], "--allow-exec");
893 assert_eq!(out2.matches("\"murk\":").count(), 1);
895 }
896
897 #[test]
898 fn add_container_when_absent_preserves_siblings() {
899 let existing = "{\n \"other\": true\n}\n";
900 let out = upsert_json_server(Some(existing), "mcpServers", "murk", &spec()).unwrap();
901 let v = parse(&out);
902 assert_eq!(v["other"], true);
903 assert_eq!(v["mcpServers"]["murk"]["command"], "murk");
904 }
905
906 #[test]
907 fn remove_leaves_other_servers_and_comments() {
908 let existing = "{\n // keep me\n \"mcpServers\": {\n \"murk\": { \"command\": \"murk\" },\n \"other\": { \"command\": \"foo\" }\n }\n}\n";
909 let out = remove_json_server(existing, "mcpServers", "murk")
910 .unwrap()
911 .expect("should remove");
912 assert!(out.contains("// keep me"));
913 let v = parse(&out);
914 assert!(v["mcpServers"]["murk"].is_null());
915 assert_eq!(v["mcpServers"]["other"]["command"], "foo");
916 }
917
918 #[test]
919 fn remove_last_member_yields_empty_container() {
920 let existing = "{\n \"mcpServers\": {\n \"murk\": { \"command\": \"murk\" }\n }\n}\n";
921 let out = remove_json_server(existing, "mcpServers", "murk")
922 .unwrap()
923 .expect("should remove");
924 let v = parse(&out);
925 assert!(v["mcpServers"]["murk"].is_null());
926 assert!(v["mcpServers"].is_object());
927 }
928
929 #[test]
930 fn remove_absent_is_none() {
931 let existing = "{\n \"mcpServers\": {\n \"other\": { \"command\": \"foo\" }\n }\n}\n";
932 assert!(
933 remove_json_server(existing, "mcpServers", "murk")
934 .unwrap()
935 .is_none()
936 );
937 }
938
939 #[test]
940 fn detect_indent_reads_four_spaces() {
941 assert_eq!(detect_indent("{\n \"a\": 1\n}"), " ");
942 assert_eq!(detect_indent("{\n\t\"a\": 1\n}"), "\t");
943 }
944
945 #[test]
946 fn detect_finds_marker_dirs() {
947 let dir = tempfile::TempDir::new().unwrap();
948 std::fs::create_dir_all(dir.path().join(".cursor")).unwrap();
949 std::fs::create_dir_all(dir.path().join(".vscode")).unwrap();
950 std::fs::create_dir_all(dir.path().join(".zed")).unwrap();
951 std::fs::create_dir_all(dir.path().join(".codex")).unwrap();
952 let ids: Vec<_> = detect(dir.path()).iter().map(|a| a.id).collect();
953 assert!(ids.contains(&"cursor"));
954 assert!(ids.contains(&"vscode"));
955 assert!(ids.contains(&"zed"));
956 assert!(ids.contains(&"codex"));
957 assert!(
958 !ids.contains(&"claude"),
959 "no .claude dir or .mcp.json present"
960 );
961 assert!(!ids.contains(&"gemini"), "no .gemini marker present");
962 }
963
964 #[test]
965 fn connect_then_disconnect_roundtrip() {
966 let dir = tempfile::TempDir::new().unwrap();
967 let a = adapter("cursor").unwrap();
968
969 let outcome = connect_client(a, dir.path(), "/keys/abc-mcp", &[]).unwrap();
970 assert!(outcome.created);
971 let written = std::fs::read_to_string(&outcome.path).unwrap();
972 let v: serde_json::Value = serde_json::from_str(&written).unwrap();
973 assert_eq!(
974 v["mcpServers"]["murk"]["env"]["MURK_KEY_FILE"],
975 "/keys/abc-mcp"
976 );
977 assert_eq!(v["mcpServers"]["murk"]["env"]["MURK_AGENT"], "1");
978
979 assert!(disconnect_client(a, dir.path()).unwrap().is_some());
980 let after = std::fs::read_to_string(a.config_path(dir.path())).unwrap();
981 let v2: serde_json::Value = serde_json::from_str(&after).unwrap();
982 assert!(v2["mcpServers"]["murk"].is_null());
983
984 assert!(disconnect_client(a, dir.path()).unwrap().is_none());
986 }
987
988 #[test]
989 fn vscode_adapter_writes_typed_stdio_and_extra_args() {
990 let dir = tempfile::TempDir::new().unwrap();
991 let a = adapter("vscode").unwrap();
992 connect_client(
993 a,
994 dir.path(),
995 "/keys/abc-mcp",
996 &["--allow-exec".to_string()],
997 )
998 .unwrap();
999 let written = std::fs::read_to_string(a.config_path(dir.path())).unwrap();
1000 let v: serde_json::Value = serde_json::from_str(&written).unwrap();
1001 assert_eq!(v["servers"]["murk"]["type"], "stdio");
1002 assert_eq!(v["servers"]["murk"]["args"][0], "mcp");
1003 assert_eq!(v["servers"]["murk"]["args"][1], "--allow-exec");
1004 }
1005
1006 #[test]
1007 fn zed_uses_context_servers_root_without_source() {
1008 let dir = tempfile::TempDir::new().unwrap();
1009 let a = adapter("zed").unwrap();
1010 connect_client(a, dir.path(), "/keys/abc-mcp", &[]).unwrap();
1011 let written = std::fs::read_to_string(a.config_path(dir.path())).unwrap();
1012 let v = parse(&written);
1013 assert_eq!(v["context_servers"]["murk"]["command"], "murk");
1014 assert_eq!(v["context_servers"]["murk"]["args"][0], "mcp");
1015 assert_eq!(
1016 v["context_servers"]["murk"]["env"]["MURK_KEY_FILE"],
1017 "/keys/abc-mcp"
1018 );
1019 assert!(v["context_servers"]["murk"]["source"].is_null());
1022 assert!(!written.contains("\"source\""));
1023 }
1024
1025 #[test]
1026 fn gemini_and_omp_use_mcpservers_root() {
1027 for id in ["gemini", "omp"] {
1028 let dir = tempfile::TempDir::new().unwrap();
1029 let a = adapter(id).unwrap();
1030 connect_client(a, dir.path(), "/keys/abc-mcp", &[]).unwrap();
1031 let v = parse(&std::fs::read_to_string(a.config_path(dir.path())).unwrap());
1032 assert_eq!(v["mcpServers"]["murk"]["command"], "murk", "adapter {id}");
1033 assert_eq!(v["mcpServers"]["murk"]["env"]["MURK_AGENT"], "1");
1034 }
1035 }
1036
1037 fn toml_spec() -> ServerSpec {
1040 ServerSpec {
1041 extra_args: vec![],
1042 key_file: "/home/alice/.config/murk/agent-keys/abc-codex".into(),
1043 typed_stdio: false,
1044 }
1045 }
1046
1047 #[test]
1048 fn toml_fresh_document_is_exact() {
1049 let out = upsert_toml_server(None, "mcp_servers", "murk", &toml_spec()).unwrap();
1050 assert_eq!(
1051 out,
1052 "[mcp_servers.murk]\n\
1053 command = \"murk\"\n\
1054 args = [\"mcp\"]\n\
1055 \n\
1056 [mcp_servers.murk.env]\n\
1057 MURK_KEY_FILE = \"/home/alice/.config/murk/agent-keys/abc-codex\"\n\
1058 MURK_AGENT = \"1\"\n"
1059 );
1060 }
1061
1062 #[test]
1063 fn toml_extra_args_land_after_mcp() {
1064 let mut s = toml_spec();
1065 s.extra_args = vec!["--allow-exec".into()];
1066 let out = upsert_toml_server(None, "mcp_servers", "murk", &s).unwrap();
1067 assert!(out.contains("args = [\"mcp\", \"--allow-exec\"]"));
1068 }
1069
1070 #[test]
1071 fn toml_preserves_other_tables_and_comments() {
1072 let existing = "# my codex config\n\
1073 model = \"o3\"\n\
1074 \n\
1075 [mcp_servers.other]\n\
1076 command = \"foo\"\n\
1077 args = [\"bar\"]\n";
1078 let out = upsert_toml_server(Some(existing), "mcp_servers", "murk", &toml_spec()).unwrap();
1079 assert!(out.contains("# my codex config"), "comment must survive");
1080 assert!(out.contains("model = \"o3\""), "top-level key must survive");
1081 assert!(
1082 out.contains("[mcp_servers.other]"),
1083 "sibling table must survive"
1084 );
1085 assert!(out.contains("command = \"foo\""));
1086 assert_eq!(out.matches("[mcp_servers.murk]").count(), 1);
1087 assert!(out.contains("[mcp_servers.murk.env]"));
1088 }
1089
1090 #[test]
1091 fn toml_idempotent_upsert_keeps_one_section() {
1092 let out1 = upsert_toml_server(None, "mcp_servers", "murk", &toml_spec()).unwrap();
1093 let mut s2 = toml_spec();
1094 s2.extra_args = vec!["--allow-exec".into()];
1095 let out2 = upsert_toml_server(Some(&out1), "mcp_servers", "murk", &s2).unwrap();
1096 assert_eq!(out2.matches("[mcp_servers.murk]").count(), 1);
1097 assert_eq!(out2.matches("[mcp_servers.murk.env]").count(), 1);
1098 assert!(out2.contains("--allow-exec"), "update must apply new args");
1099 }
1100
1101 #[test]
1102 fn toml_remove_leaves_siblings_and_comments() {
1103 let existing = "# keep me\n\
1104 [mcp_servers.other]\n\
1105 command = \"foo\"\n\
1106 \n\
1107 [mcp_servers.murk]\n\
1108 command = \"murk\"\n\
1109 args = [\"mcp\"]\n\
1110 \n\
1111 [mcp_servers.murk.env]\n\
1112 MURK_AGENT = \"1\"\n";
1113 let out = remove_toml_server(existing, "mcp_servers", "murk")
1114 .unwrap()
1115 .expect("should remove");
1116 assert!(out.contains("# keep me"));
1117 assert!(out.contains("[mcp_servers.other]"));
1118 assert!(!out.contains("[mcp_servers.murk]"));
1119 assert!(!out.contains("[mcp_servers.murk.env]"));
1120 }
1121
1122 #[test]
1123 fn toml_remove_absent_is_none() {
1124 let existing = "[mcp_servers.other]\ncommand = \"foo\"\n";
1125 assert!(
1126 remove_toml_server(existing, "mcp_servers", "murk")
1127 .unwrap()
1128 .is_none()
1129 );
1130 }
1131
1132 #[test]
1133 fn codex_adapter_roundtrip() {
1134 let dir = tempfile::TempDir::new().unwrap();
1135 let a = adapter("codex").unwrap();
1136 assert_eq!(a.format, ConfigFormat::Toml);
1137
1138 connect_client(a, dir.path(), "/keys/abc-codex", &[]).unwrap();
1139 let path = a.config_path(dir.path());
1140 assert!(path.ends_with(".codex/config.toml"));
1141 let written = std::fs::read_to_string(&path).unwrap();
1142 assert!(written.contains("[mcp_servers.murk]"));
1143 assert!(written.contains("MURK_KEY_FILE = \"/keys/abc-codex\""));
1144 assert!(!written.contains("AGE-SECRET-KEY"));
1145
1146 assert!(disconnect_client(a, dir.path()).unwrap().is_some());
1147 let after = std::fs::read_to_string(&path).unwrap();
1148 assert!(!after.contains("[mcp_servers.murk]"));
1149 assert!(disconnect_client(a, dir.path()).unwrap().is_none());
1151 }
1152}