Skip to main content

murk_cli/
connect.rs

1//! Wiring `murk mcp` into AI-editor MCP client configs — the engine behind
2//! `murk agent connect` / `murk agent disconnect`.
3//!
4//! Every client stores MCP servers in its own file with its own root key
5//! (`mcpServers`, or `servers` for VS Code) and its own formatting, and those
6//! files are frequently hand-edited and full of `//` comments. So we never
7//! reserialize the whole document: we surgically upsert (or remove) only the
8//! `murk` entry, leaving every other server, key, and comment byte-for-byte
9//! intact. The only thing written into a config is a `MURK_KEY_FILE` *path* plus
10//! `MURK_AGENT=1` — never inline key material — so even a committed config leaks
11//! nothing but a machine-local path.
12
13use crate::error::MurkError;
14use std::path::{Path, PathBuf};
15
16/// The stdio server entry we write for `murk`.
17pub(crate) struct ServerSpec {
18    /// Extra args after `mcp` (e.g. `--allow-exec`).
19    pub extra_args: Vec<String>,
20    /// Absolute path to the grant key file (`MURK_KEY_FILE`).
21    pub key_file: String,
22    /// Whether to emit `"type": "stdio"` (VS Code's schema requires it).
23    pub typed_stdio: bool,
24}
25
26/// JSON-escape and quote a string via serde_json, so paths with backslashes or
27/// quotes (Windows, odd homedirs) are always valid JSON.
28fn quote(s: &str) -> String {
29    serde_json::to_string(s).unwrap_or_else(|_| format!("{s:?}"))
30}
31
32/// Render the `murk` server *value* object (`{ ... }`), inner lines indented at
33/// `member_indent + unit`, closing brace aligned to `member_indent`.
34fn 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
55/// Render a full `"name": { ... }` member at `member_indent`.
56fn 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
64// ---- JSONC scanner (string- and comment-aware) -----------------------------
65
66/// Skip whitespace and `//` / `/* */` comments from `i`; return the next
67/// significant byte index.
68fn 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
90/// Index just past a `"..."` string beginning at `i`.
91fn 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
103/// Index just past a `{...}` or `[...]` beginning at `i`.
104fn 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
134/// Index just past the JSON value starting at (or after trivia from) `i`.
135fn 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
157/// A direct member of a JSON object.
158struct Member {
159    key: String,
160    key_start: usize,
161    value_end: usize,
162}
163
164/// Parse the direct members of the object whose opening `{` is at `open`.
165/// Returns the closing `}` index and the members.
166fn 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
202/// The opening `{` of the top-level object, if the document is one.
203fn 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
208/// Detect the indent unit (first indentation run after a newline). Defaults to
209/// two spaces.
210fn 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
223/// The indentation (leading whitespace) of the line containing byte `idx`.
224fn 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
236/// Build a fresh document containing only the `murk` server under `root_key`.
237fn 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
245/// Insert `server` as the first member of the object whose `{` is at `obj_open`.
246/// `member_indent` is the indentation the new member should carry.
247fn 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        // Align the closing brace to its own line under the object's owner.
262        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        // Skip the original whitespace between `{` and `}` — we rebuilt it.
272        out.push_str(&text[close..]);
273    } else {
274        out.push_str(&text[after_open..]);
275    }
276    out
277}
278
279/// Upsert the `murk` server entry into `existing` (a client config) under
280/// `root_key`, preserving all other content and comments. `None`/blank input
281/// yields a fresh document.
282pub(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    // Locate the root-key container (mcpServers / servers).
300    let Some(root_member) = top_members.iter().find(|m| m.key == root_key) else {
301        // No container yet: add one holding just murk, as the first top member.
302        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    // The container's value must be an object.
321    // Re-find the container value's opening brace precisely.
322    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        // Replace just murk's value span.
335        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    // Add murk as the first member of the existing container.
348    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
365/// Splice a pre-rendered top-level member (`"key": {...}`) as the first member
366/// of the top object.
367fn 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
393/// Find the `:` after an object key that starts at `key_start`.
394fn 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/// Remove the `murk` server entry from `existing`. Returns `None` if there was
401/// nothing to remove (no such entry), else the rewritten config.
402// `Result` mirrors `upsert_json_server` and leaves room to reject malformed
403// input later; today every failure path is a benign "nothing to remove".
404#[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    // Delete from the start of the member's line through its value, plus the
436    // comma that binds it to a sibling (trailing if any, else the preceding one)
437    // so the result stays valid JSON.
438    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        // Has a following sibling: drop trailing comma and the blank line left.
448        del_end = after + 1;
449    } else {
450        // Last member: drop the preceding comma instead.
451        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    // Trim a trailing newline so we don't leave a blank line behind.
460    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// ---- Client adapters -------------------------------------------------------
471
472/// The on-disk format of a client's MCP config.
473#[derive(Debug, PartialEq, Eq, Clone, Copy)]
474pub enum ConfigFormat {
475    /// JSON/JSONC with a server-map object (Claude, Cursor, VS Code, Zed,
476    /// Gemini, omp).
477    Json,
478    /// TOML with `[mcp_servers.<name>]` tables (Codex).
479    Toml,
480}
481
482/// A supported MCP client: where its project-local config lives, the root key
483/// its server map uses (or table namespace for TOML), whether an entry needs a
484/// typed `"type": "stdio"`, the on-disk format, and a marker directory whose
485/// presence signals the client is used in a project.
486pub struct ClientAdapter {
487    pub id: &'static str,
488    pub display: &'static str,
489    /// Project-local config path, relative to the repo root.
490    pub rel_path: &'static str,
491    /// Directory whose presence auto-detects the client (besides the config).
492    pub marker_dir: &'static str,
493    /// Root key holding the server map (`mcpServers`; `servers` for VS Code;
494    /// `context_servers` for Zed) — or the table namespace (`mcp_servers`) for
495    /// TOML clients.
496    pub root_key: &'static str,
497    /// VS Code requires an explicit `"type": "stdio"` on each server.
498    pub typed_stdio: bool,
499    /// On-disk config format.
500    pub format: ConfigFormat,
501}
502
503/// Supported clients. Each writes a project-local config so a repo's grant
504/// never leaks into a global, cross-repo config.
505pub 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
571/// Look up an adapter by id (case-insensitive).
572pub fn adapter(id: &str) -> Option<&'static ClientAdapter> {
573    ADAPTERS.iter().find(|a| a.id.eq_ignore_ascii_case(id))
574}
575
576/// Adapters whose config file or marker directory already exists under
577/// `project_dir` — the clients this repo actually uses.
578pub 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    /// The client's project-local config path under `project_dir`.
589    pub(crate) fn config_path(&self, project_dir: &Path) -> PathBuf {
590        project_dir.join(self.rel_path)
591    }
592}
593
594/// Outcome of wiring one client.
595pub struct ConnectOutcome {
596    pub path: PathBuf,
597    /// The config file was newly created (vs. an existing one updated).
598    pub created: bool,
599}
600
601/// Upsert the `murk` server into `adapter`'s config under `project_dir`, writing
602/// only a `MURK_KEY_FILE` path (`key_file`) + `MURK_AGENT=1` and any `extra_args`
603/// after `mcp`. Preserves every other server and comment; atomic (temp+rename).
604pub 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
634/// Remove the `murk` server from `adapter`'s config. Returns the path when a
635/// change was written, `None` when there was nothing to remove.
636pub 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
659/// Atomic config write: create parent directories, then temp-file + rename so a
660/// crash mid-write can never truncate an existing config.
661fn 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
677// ---- TOML writer (Codex config.toml) ---------------------------------------
678//
679// Codex stores MCP servers as `[mcp_servers.<name>]` tables (with a nested
680// `[mcp_servers.<name>.env]` table). We edit surgically like the JSON writer:
681// drop any existing murk tables, then append fresh ones, leaving every other
682// table and comment untouched. `typed_stdio` doesn't apply (Codex has no typed
683// server flag).
684
685/// The dotted key path of a TOML table header line (`[a.b]` or `[[a.b]]`),
686/// ignoring a trailing `# comment`. `None` for non-header lines.
687fn 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
694/// Line ranges `[start, end)` of every table under `<prefix>.<server>` (each
695/// header plus its body, through the line before the next unrelated header).
696fn 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
721/// Render murk's `[prefix.server]` + `[prefix.server.env]` tables.
722fn 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/// Remove murk's tables from a TOML config. `None` when there was nothing to
738/// remove; else the rewritten document.
739#[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
768/// Upsert murk's tables into a TOML config, preserving all other tables and
769/// comments. `None`/blank input yields a fresh document.
770pub(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    /// Strip `//` and `/* */` comments (string-aware) so a preserved-comment
808    /// JSONC document can still be validated as JSON in tests.
809    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    /// Parse writer output, tolerating preserved `//` and `/* */` comments.
849    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        // Exactly one murk *key* (the `command: "murk"` value also contains the word).
894        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        // Disconnecting again is a no-op.
985        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        // Current Zed (post m_2025_06_27 migration) takes a flat entry — no
1020        // `source` discriminator. Emitting one is not required and we don't.
1021        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    // ---- TOML writer (Codex) ----
1038
1039    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        // Disconnecting again is a no-op.
1150        assert!(disconnect_client(a, dir.path()).unwrap().is_none());
1151    }
1152}