Skip to main content

agentstatedeveloper_cli/commands/
mcp.rs

1//! `asd mcp` — install, uninstall, and inspect the asd-mcp server
2//! registration in known agent tools.
3//!
4//! All tools currently registered share the de-facto-standard config shape: a
5//! JSON file with a top-level `mcpServers` object keyed by server name, each
6//! entry `{ command, args, env }`. New tools that follow that convention are a
7//! one-line addition to [`TOOLS`]. Tools with a divergent shape (TOML configs,
8//! a `context_servers`/`servers` key, or a different entry layout) need the
9//! `Tool` model generalized first — tracked as a follow-up slice.
10
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use clap::{Args, Subcommand};
15
16use crate::config::Config;
17
18/// The server name asd registers itself under inside `mcpServers`.
19const SERVER_NAME: &str = "asd";
20
21// ---------------------------------------------------------------------------
22// Known tools
23// ---------------------------------------------------------------------------
24
25/// Serialization format of a tool's config file. JSON/JSONC tools share the
26/// `serde_json::Value` read/insert/write path (JSONC strips comments on read);
27/// TOML tools are edited with `toml_edit` to preserve comments and ordering.
28#[derive(Clone, Copy, PartialEq)]
29enum ConfigFormat {
30    Json,
31    /// JSON with comments (e.g. Kilo Code's kilo.jsonc). Comments are stripped
32    /// on read; the rewrite is plain JSON (comments are not preserved).
33    Jsonc,
34    Toml,
35    /// A config we won't auto-edit safely (e.g. Aider's hand-maintained
36    /// `.aider.conf.yml` — YAML with no comment-preserving editor). `asd mcp`
37    /// prints manual setup guidance instead of writing the file.
38    Manual,
39}
40
41/// Shape of a single server entry under the tool's server map. The server map
42/// itself is an object keyed by server name; only the per-entry fields differ.
43#[derive(Clone, Copy)]
44enum EntryStyle {
45    /// `{ command, args, env }` — the de-facto standard (Claude*, Cursor,
46    /// Gemini CLI, Windsurf, Cline, Codex).
47    Standard,
48    /// Zed `context_servers`: standard fields plus `"source": "custom"`.
49    ZedCustom,
50    /// VS Code `servers`: standard fields plus `"type": "stdio"`.
51    VsCodeStdio,
52}
53
54struct Tool {
55    name: &'static str,
56    config_path_fn: fn() -> Option<PathBuf>,
57    format: ConfigFormat,
58    /// Top-level key holding the server map.
59    servers_key: &'static str,
60    entry_style: EntryStyle,
61}
62
63const TOOLS: &[Tool] = &[
64    Tool {
65        name: "claude-code",
66        config_path_fn: claude_code_config_path,
67        format: ConfigFormat::Json,
68        servers_key: "mcpServers",
69        entry_style: EntryStyle::Standard,
70    },
71    Tool {
72        name: "claude-desktop",
73        config_path_fn: claude_desktop_config_path,
74        format: ConfigFormat::Json,
75        servers_key: "mcpServers",
76        entry_style: EntryStyle::Standard,
77    },
78    Tool {
79        name: "cursor",
80        config_path_fn: cursor_config_path,
81        format: ConfigFormat::Json,
82        servers_key: "mcpServers",
83        entry_style: EntryStyle::Standard,
84    },
85    Tool {
86        name: "gemini-cli",
87        config_path_fn: gemini_cli_config_path,
88        format: ConfigFormat::Json,
89        servers_key: "mcpServers",
90        entry_style: EntryStyle::Standard,
91    },
92    Tool {
93        name: "windsurf",
94        config_path_fn: windsurf_config_path,
95        format: ConfigFormat::Json,
96        servers_key: "mcpServers",
97        entry_style: EntryStyle::Standard,
98    },
99    Tool {
100        name: "zed",
101        config_path_fn: zed_config_path,
102        format: ConfigFormat::Json,
103        servers_key: "context_servers",
104        entry_style: EntryStyle::ZedCustom,
105    },
106    Tool {
107        name: "vscode",
108        config_path_fn: vscode_config_path,
109        format: ConfigFormat::Json,
110        servers_key: "servers",
111        entry_style: EntryStyle::VsCodeStdio,
112    },
113    Tool {
114        name: "cline",
115        config_path_fn: cline_config_path,
116        format: ConfigFormat::Json,
117        servers_key: "mcpServers",
118        entry_style: EntryStyle::Standard,
119    },
120    Tool {
121        name: "codex-cli",
122        config_path_fn: codex_cli_config_path,
123        format: ConfigFormat::Toml,
124        servers_key: "mcp_servers",
125        entry_style: EntryStyle::Standard,
126    },
127    Tool {
128        name: "kilo-code",
129        config_path_fn: kilo_code_config_path,
130        format: ConfigFormat::Jsonc,
131        servers_key: "mcpServers",
132        entry_style: EntryStyle::Standard,
133    },
134    Tool {
135        name: "antigravity",
136        config_path_fn: antigravity_config_path,
137        format: ConfigFormat::Json,
138        servers_key: "mcpServers",
139        entry_style: EntryStyle::Standard,
140    },
141    Tool {
142        name: "aider",
143        config_path_fn: aider_config_path,
144        format: ConfigFormat::Manual,
145        servers_key: "mcp-servers",
146        entry_style: EntryStyle::Standard,
147    },
148];
149
150/// Comma-separated list of registered tool names, for help text and errors.
151/// Derived from [`TOOLS`] so it can never drift out of sync.
152fn valid_tool_names() -> String {
153    TOOLS.iter().map(|t| t.name).collect::<Vec<_>>().join(", ")
154}
155
156fn home() -> Option<PathBuf> {
157    std::env::var("HOME").ok().map(PathBuf::from)
158}
159
160fn claude_code_config_path() -> Option<PathBuf> {
161    Some(home()?.join(".claude.json"))
162}
163
164fn claude_desktop_config_path() -> Option<PathBuf> {
165    #[cfg(target_os = "macos")]
166    return Some(home()?.join("Library/Application Support/Claude/claude_desktop_config.json"));
167    #[cfg(target_os = "linux")]
168    return Some(home()?.join(".config/Claude/claude_desktop_config.json"));
169    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
170    return None;
171}
172
173fn cursor_config_path() -> Option<PathBuf> {
174    Some(home()?.join(".cursor/mcp.json"))
175}
176
177fn gemini_cli_config_path() -> Option<PathBuf> {
178    Some(home()?.join(".gemini/settings.json"))
179}
180
181fn windsurf_config_path() -> Option<PathBuf> {
182    Some(home()?.join(".codeium/windsurf/mcp_config.json"))
183}
184
185fn zed_config_path() -> Option<PathBuf> {
186    Some(home()?.join(".config/zed/settings.json"))
187}
188
189/// VS Code user-profile MCP config (`mcp.json`, top-level `servers`).
190fn vscode_config_path() -> Option<PathBuf> {
191    #[cfg(target_os = "macos")]
192    return Some(home()?.join("Library/Application Support/Code/User/mcp.json"));
193    #[cfg(target_os = "linux")]
194    return Some(home()?.join(".config/Code/User/mcp.json"));
195    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
196    return None;
197}
198
199/// Cline stores MCP settings in the VS Code extension's globalStorage.
200fn cline_config_path() -> Option<PathBuf> {
201    let rel = "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json";
202    #[cfg(target_os = "macos")]
203    return Some(
204        home()?
205            .join("Library/Application Support/Code/User")
206            .join(rel),
207    );
208    #[cfg(target_os = "linux")]
209    return Some(home()?.join(".config/Code/User").join(rel));
210    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
211    return None;
212}
213
214fn codex_cli_config_path() -> Option<PathBuf> {
215    Some(home()?.join(".codex/config.toml"))
216}
217
218/// Kilo Code (v7.0.33+) uses a JSONC config under XDG config.
219fn kilo_code_config_path() -> Option<PathBuf> {
220    Some(home()?.join(".config/kilo/kilo.jsonc"))
221}
222
223/// Google Antigravity's global MCP config (separate from Gemini CLI's
224/// settings.json, though both live under ~/.gemini).
225fn antigravity_config_path() -> Option<PathBuf> {
226    Some(home()?.join(".gemini/config/mcp_config.json"))
227}
228
229/// Aider's config — YAML, hand-maintained, edited manually (see ConfigFormat::Manual).
230fn aider_config_path() -> Option<PathBuf> {
231    Some(home()?.join(".aider.conf.yml"))
232}
233
234// ---------------------------------------------------------------------------
235// Subcommands
236// ---------------------------------------------------------------------------
237
238#[derive(Debug, Subcommand)]
239pub enum McpCmd {
240    /// Register asd-mcp in all detected agent tool configs.
241    Install(InstallArgs),
242
243    /// Remove asd-mcp from all detected agent tool configs.
244    Uninstall(UninstallArgs),
245
246    /// Show asd-mcp registration status across known agent tools.
247    Status(StatusArgs),
248
249    /// Inject (or refresh) a managed ASD usage block into the repo's agent
250    /// instruction files (AGENTS.md + CLAUDE.md by default).
251    Instructions(InstructionsArgs),
252}
253
254#[derive(Debug, Args)]
255pub struct InstallArgs {
256    /// Path to the ASD database to wire into the MCP server.
257    /// Defaults to <current-dir>/.asd-state.db (resolved to an absolute path).
258    #[arg(long)]
259    pub db: Option<PathBuf>,
260
261    /// Only install for a specific tool (see `asd mcp status` for the full
262    /// list of known tools). Installs into all detected tools when omitted.
263    #[arg(long)]
264    pub tool: Option<String>,
265
266    /// Plan Q t-003: omit the pinned `ASD_DB` from the MCP config so the server
267    /// follows the active repo (`asd repo use`) and hot-swaps when it changes —
268    /// one MCP registration that tracks whichever project you're working on,
269    /// instead of pinning one database.
270    #[arg(long)]
271    pub follow_active: bool,
272
273    /// Plan S t-005: write a project-local `.mcp.json` in the current directory
274    /// (pinned to this project's db) instead of editing each tool's global MCP
275    /// config. The explicit way to give one project its own isolated MCP server
276    /// — useful when several projects share a global agent config.
277    #[arg(long)]
278    pub project: bool,
279}
280
281#[derive(Debug, Args)]
282pub struct UninstallArgs {
283    /// Only uninstall from a specific tool.
284    #[arg(long)]
285    pub tool: Option<String>,
286}
287
288#[derive(Debug, Args)]
289pub struct StatusArgs {}
290
291#[derive(Debug, Args)]
292pub struct InstructionsArgs {
293    /// Instruction files to write, repo-relative. Defaults to AGENTS.md +
294    /// CLAUDE.md (the most widely-read conventions). Repeatable.
295    #[arg(long)]
296    pub file: Vec<String>,
297
298    /// Remove the managed ASD block instead of writing it.
299    #[arg(long)]
300    pub remove: bool,
301
302    /// Also add a non-blocking Claude Code PreToolUse hook (on Grep/Glob) to
303    /// the project's .claude/settings.json that nudges toward ASD's structured
304    /// search. Repo-scoped, not global. Honors --remove.
305    #[arg(long)]
306    pub hooks: bool,
307}
308
309// ---------------------------------------------------------------------------
310// Entry point
311// ---------------------------------------------------------------------------
312
313pub fn run(_cfg: &Config, cmd: McpCmd) -> Result<()> {
314    match cmd {
315        McpCmd::Install(args) => {
316            let result = install(args);
317            // Best-effort: refresh the shared cross-tool help index after
318            // registering asd, so a unified `help` can discover asd's features.
319            // A write failure must not fail the install.
320            if result.is_ok() {
321                match crate::commands::help::publish_help_index() {
322                    Ok(path) => eprintln!("  help index: {}", path.display()),
323                    Err(e) => eprintln!("  help index skipped: {e}"),
324                }
325            }
326            result
327        }
328        McpCmd::Uninstall(args) => uninstall(args),
329        McpCmd::Status(_) => status(),
330        McpCmd::Instructions(args) => instructions(args),
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Helpers
336// ---------------------------------------------------------------------------
337
338/// Locate the asd-mcp binary: prefer the one next to the running asd binary,
339/// then ~/.cargo/bin/asd-mcp, then whatever is on PATH.
340fn find_asd_mcp() -> Option<PathBuf> {
341    // Same directory as the current executable (e.g. ~/.cargo/bin/).
342    if let Ok(exe) = std::env::current_exe() {
343        let sibling = exe.with_file_name("asd-mcp");
344        if sibling.exists() {
345            return Some(sibling);
346        }
347    }
348    // Explicit ~/.cargo/bin fallback.
349    if let Some(home) = home() {
350        let p = home.join(".cargo/bin/asd-mcp");
351        if p.exists() {
352            return Some(p);
353        }
354    }
355    // PATH search.
356    which_asd_mcp()
357}
358
359fn which_asd_mcp() -> Option<PathBuf> {
360    std::env::var_os("PATH")?
361        .to_string_lossy()
362        .split(':')
363        .find_map(|dir| {
364            let p = PathBuf::from(dir).join("asd-mcp");
365            p.exists().then_some(p)
366        })
367}
368
369fn resolve_db(override_db: Option<PathBuf>) -> Result<PathBuf> {
370    let p = match override_db {
371        Some(p) => p,
372        None => std::env::current_dir()
373            .context("cannot determine current directory")?
374            .join(".asd-state.db"),
375    };
376    // Canonicalize if it exists; otherwise just make it absolute.
377    if p.exists() {
378        p.canonicalize().context("canonicalize db path")
379    } else {
380        if p.is_absolute() {
381            Ok(p)
382        } else {
383            Ok(std::env::current_dir()?.join(p))
384        }
385    }
386}
387
388/// Read a JSON/JSONC config file, returning an empty object if it doesn't
389/// exist. For JSONC, comments are stripped (string-aware) before parsing.
390fn read_config(path: &Path, format: ConfigFormat) -> Result<serde_json::Value> {
391    if !path.exists() {
392        return Ok(serde_json::json!({}));
393    }
394    let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
395    let raw = if format == ConfigFormat::Jsonc {
396        strip_jsonc_comments(&raw)
397    } else {
398        raw
399    };
400    serde_json::from_str(&raw).with_context(|| format!("parse JSON in {}", path.display()))
401}
402
403/// Strip `//` line and `/* */` block comments from JSONC, leaving comment-like
404/// sequences inside string literals (e.g. `"http://x"`) untouched. UTF-8 safe.
405fn strip_jsonc_comments(s: &str) -> String {
406    let mut out = String::with_capacity(s.len());
407    let mut chars = s.chars().peekable();
408    let mut in_str = false;
409    while let Some(c) = chars.next() {
410        if in_str {
411            out.push(c);
412            if c == '\\' {
413                if let Some(n) = chars.next() {
414                    out.push(n);
415                }
416            } else if c == '"' {
417                in_str = false;
418            }
419        } else if c == '"' {
420            in_str = true;
421            out.push('"');
422        } else if c == '/' && chars.peek() == Some(&'/') {
423            for n in chars.by_ref() {
424                if n == '\n' {
425                    out.push('\n');
426                    break;
427                }
428            }
429        } else if c == '/' && chars.peek() == Some(&'*') {
430            chars.next(); // consume '*'
431            let mut prev = '\0';
432            for n in chars.by_ref() {
433                if prev == '*' && n == '/' {
434                    break;
435                }
436                prev = n;
437            }
438        } else {
439            out.push(c);
440        }
441    }
442    out
443}
444
445/// Write a JSON config file, creating parent directories as needed.
446fn write_config(path: &Path, value: &serde_json::Value) -> Result<()> {
447    if let Some(parent) = path.parent() {
448        std::fs::create_dir_all(parent)
449            .with_context(|| format!("create dirs for {}", path.display()))?;
450    }
451    let out = serde_json::to_string_pretty(value)?;
452    std::fs::write(path, out).with_context(|| format!("write {}", path.display()))
453}
454
455/// Build the server entry for a tool, applying any style-specific extra fields
456/// on top of the standard `{ command, args, env }` body. `db_path = None`
457/// (t-003 `--follow-active`) omits `ASD_DB` so the server follows the registry.
458fn build_entry(style: EntryStyle, asd_mcp_bin: &Path, db_path: Option<&Path>) -> serde_json::Value {
459    let env = match db_path {
460        Some(p) => serde_json::json!({ "ASD_DB": p.to_string_lossy() }),
461        None => serde_json::json!({}),
462    };
463    let mut entry = serde_json::json!({
464        "command": asd_mcp_bin.to_string_lossy(),
465        "args": [],
466        "env": env
467    });
468    let obj = entry.as_object_mut().expect("entry is an object");
469    match style {
470        EntryStyle::Standard => {}
471        EntryStyle::ZedCustom => {
472            obj.insert("source".to_string(), serde_json::json!("custom"));
473        }
474        EntryStyle::VsCodeStdio => {
475            obj.insert("type".to_string(), serde_json::json!("stdio"));
476        }
477    }
478    entry
479}
480
481fn tools_to_process(tool_filter: Option<&str>) -> Vec<&'static Tool> {
482    TOOLS
483        .iter()
484        .filter(|t| tool_filter.map_or(true, |f| t.name == f))
485        .collect()
486}
487
488/// Insert (or overwrite) the asd server entry under `servers_key` in `cfg`.
489/// Returns `true` if an asd entry was already present (i.e. this was an update).
490fn upsert_server(
491    cfg: &mut serde_json::Value,
492    servers_key: &str,
493    entry: &serde_json::Value,
494) -> Result<bool> {
495    let servers = cfg
496        .as_object_mut()
497        .context("config root is not an object")?
498        .entry(servers_key)
499        .or_insert_with(|| serde_json::json!({}));
500    let servers = servers
501        .as_object_mut()
502        .with_context(|| format!("{servers_key} is not an object"))?;
503    let already = servers.contains_key(SERVER_NAME);
504    servers.insert(SERVER_NAME.to_string(), entry.clone());
505    Ok(already)
506}
507
508/// Remove the asd server entry from `servers_key` in `cfg`.
509/// Returns `true` if an entry was present and removed.
510fn remove_server(cfg: &mut serde_json::Value, servers_key: &str) -> bool {
511    cfg.get_mut(servers_key)
512        .and_then(|s| s.as_object_mut())
513        .map(|m| m.remove(SERVER_NAME).is_some())
514        .unwrap_or(false)
515}
516
517// --- TOML path (toml_edit, format-preserving) ------------------------------
518
519/// Insert/overwrite the asd server entry in a TOML config, preserving the rest
520/// of the document (comments, ordering, other tables). Returns whether an asd
521/// entry already existed. Used for Codex CLI's `~/.codex/config.toml`.
522fn toml_upsert_server(
523    path: &Path,
524    servers_key: &str,
525    asd_mcp: &Path,
526    db_path: Option<&Path>,
527) -> Result<bool> {
528    use toml_edit::{Array, DocumentMut, Item, Table, value};
529
530    let mut doc = if path.exists() {
531        std::fs::read_to_string(path)
532            .with_context(|| format!("read {}", path.display()))?
533            .parse::<DocumentMut>()
534            .with_context(|| format!("parse TOML in {}", path.display()))?
535    } else {
536        DocumentMut::new()
537    };
538
539    let servers = doc
540        .as_table_mut()
541        .entry(servers_key)
542        .or_insert(Item::Table(Table::new()))
543        .as_table_mut()
544        .with_context(|| format!("{servers_key} is not a table"))?;
545    // Render as `[mcp_servers.asd]` headers rather than an explicit, empty
546    // `[mcp_servers]` parent.
547    servers.set_implicit(true);
548
549    let already = servers.contains_key(SERVER_NAME);
550
551    let mut entry = Table::new();
552    entry["command"] = value(asd_mcp.to_string_lossy().into_owned());
553    entry["args"] = value(Array::new());
554    let mut env = Table::new();
555    if let Some(p) = db_path {
556        env["ASD_DB"] = value(p.to_string_lossy().into_owned());
557    }
558    entry["env"] = Item::Table(env);
559    servers[SERVER_NAME] = Item::Table(entry);
560
561    if let Some(parent) = path.parent() {
562        std::fs::create_dir_all(parent)
563            .with_context(|| format!("create dirs for {}", path.display()))?;
564    }
565    std::fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display()))?;
566    Ok(already)
567}
568
569/// Remove the asd server entry from a TOML config, preserving the rest.
570/// Returns `true` if an entry was present and removed.
571fn toml_remove_server(path: &Path, servers_key: &str) -> Result<bool> {
572    use toml_edit::DocumentMut;
573
574    if !path.exists() {
575        return Ok(false);
576    }
577    let mut doc = std::fs::read_to_string(path)
578        .with_context(|| format!("read {}", path.display()))?
579        .parse::<DocumentMut>()
580        .with_context(|| format!("parse TOML in {}", path.display()))?;
581    let removed = doc
582        .get_mut(servers_key)
583        .and_then(|i| i.as_table_mut())
584        .map(|t| t.remove(SERVER_NAME).is_some())
585        .unwrap_or(false);
586    if removed {
587        std::fs::write(path, doc.to_string())
588            .with_context(|| format!("write {}", path.display()))?;
589    }
590    Ok(removed)
591}
592
593/// Read the registered asd entry's `(command, ASD_DB)` from a config file of
594/// either format, if present. Read-only — used by `status`.
595fn read_registered_entry(
596    path: &Path,
597    format: ConfigFormat,
598    servers_key: &str,
599) -> Option<(String, String)> {
600    match format {
601        ConfigFormat::Json | ConfigFormat::Jsonc => {
602            let cfg = read_config(path, format).ok()?;
603            let e = cfg.get(servers_key)?.get(SERVER_NAME)?;
604            let cmd = e.get("command")?.as_str()?.to_string();
605            let db = e.get("env")?.get("ASD_DB")?.as_str()?.to_string();
606            Some((cmd, db))
607        }
608        ConfigFormat::Toml => {
609            let doc = std::fs::read_to_string(path)
610                .ok()?
611                .parse::<toml_edit::DocumentMut>()
612                .ok()?;
613            let e = doc.get(servers_key)?.get(SERVER_NAME)?;
614            let cmd = e.get("command")?.as_str()?.to_string();
615            let db = e.get("env")?.get("ASD_DB")?.as_str()?.to_string();
616            Some((cmd, db))
617        }
618        ConfigFormat::Manual => None,
619    }
620}
621
622// ---------------------------------------------------------------------------
623// install
624// ---------------------------------------------------------------------------
625
626fn install(args: InstallArgs) -> Result<()> {
627    let asd_mcp = find_asd_mcp().ok_or_else(|| {
628        anyhow::anyhow!(
629            "asd-mcp binary not found. Install it with:\n  \
630             cargo install --path crates/agentstatedeveloper-mcp"
631        )
632    })?;
633
634    // Plan S t-005: `--project` writes a single project-local `.mcp.json` (pinned
635    // to this project's db) instead of touching each tool's global config.
636    if args.project {
637        let cfg_path = std::env::current_dir()
638            .context("cannot determine current directory")?
639            .join(".mcp.json");
640        let db_opt: Option<PathBuf> = if args.follow_active {
641            None
642        } else {
643            Some(resolve_db(args.db)?)
644        };
645        let entry = build_entry(EntryStyle::Standard, &asd_mcp, db_opt.as_deref());
646        let mut cfg = read_config(&cfg_path, ConfigFormat::Json)?;
647        let already = upsert_server(&mut cfg, "mcpServers", &entry)?;
648        write_config(&cfg_path, &cfg)?;
649
650        eprintln!(
651            "  asd {} {}",
652            if already {
653                "updated in"
654            } else {
655                "installed to"
656            },
657            cfg_path.display()
658        );
659        eprintln!(
660            "  ASD_DB:  {}",
661            db_opt
662                .as_ref()
663                .map(|p| p.display().to_string())
664                .unwrap_or_else(
665                    || "(unset — server resolves this project by directory)".to_string()
666                )
667        );
668        eprintln!();
669        eprintln!(
670            "Project-scoped MCP config written. Restart your agent in this project; \
671             it targets this repo's db regardless of any other open sessions."
672        );
673        return Ok(());
674    }
675
676    // t-003: `--follow-active` omits the pinned ASD_DB so the server tracks the
677    // registry's active repo; otherwise pin the resolved db as before.
678    let db_opt: Option<PathBuf> = if args.follow_active {
679        None
680    } else {
681        Some(resolve_db(args.db)?)
682    };
683    let db_display = db_opt
684        .as_ref()
685        .map(|p| p.display().to_string())
686        .unwrap_or_else(|| "(unset — follows the active repo, `asd repo use`)".to_string());
687    let tools = tools_to_process(args.tool.as_deref());
688
689    if tools.is_empty() {
690        anyhow::bail!(
691            "unknown tool {:?}; valid: {}",
692            args.tool,
693            valid_tool_names()
694        );
695    }
696
697    let mut installed = 0usize;
698    let mut manual = 0usize;
699
700    for tool in &tools {
701        let Some(cfg_path) = (tool.config_path_fn)() else {
702            continue;
703        };
704
705        // Tools we won't auto-edit: print manual setup guidance and move on.
706        if tool.format == ConfigFormat::Manual {
707            manual += 1;
708            eprintln!("  {} — manual setup (not auto-edited):", tool.name);
709            eprintln!("      add an MCP server to {} with", cfg_path.display());
710            eprintln!("        command: {}", asd_mcp.display());
711            eprintln!("        env ASD_DB: {}", db_display);
712            eprintln!(
713                "      (key `{}`; see the tool's MCP docs for the exact schema)",
714                tool.servers_key
715            );
716            continue;
717        }
718
719        let already = match tool.format {
720            ConfigFormat::Json | ConfigFormat::Jsonc => {
721                let entry = build_entry(tool.entry_style, &asd_mcp, db_opt.as_deref());
722                let mut cfg = read_config(&cfg_path, tool.format)?;
723                let already = upsert_server(&mut cfg, tool.servers_key, &entry)?;
724                write_config(&cfg_path, &cfg)?;
725                already
726            }
727            ConfigFormat::Toml => {
728                toml_upsert_server(&cfg_path, tool.servers_key, &asd_mcp, db_opt.as_deref())?
729            }
730            ConfigFormat::Manual => unreachable!("handled above"),
731        };
732
733        if already {
734            eprintln!("  {} updated  {}", tool.name, cfg_path.display());
735        } else {
736            eprintln!("  {} installed  {}", tool.name, cfg_path.display());
737        }
738        installed += 1;
739    }
740
741    if installed == 0 && manual == 0 {
742        eprintln!("No matching tool config files found.");
743        eprintln!("Checked:");
744        for tool in &tools {
745            if let Some(p) = (tool.config_path_fn)() {
746                eprintln!("  {} → {}", tool.name, p.display());
747            }
748        }
749    } else {
750        eprintln!();
751        eprintln!("  asd-mcp binary:  {}", asd_mcp.display());
752        eprintln!("  ASD_DB:          {}", db_display);
753        eprintln!();
754        eprintln!("Restart your agent tool(s) to activate the MCP server.");
755    }
756
757    Ok(())
758}
759
760// ---------------------------------------------------------------------------
761// uninstall
762// ---------------------------------------------------------------------------
763
764fn uninstall(args: UninstallArgs) -> Result<()> {
765    let tools = tools_to_process(args.tool.as_deref());
766
767    if tools.is_empty() {
768        anyhow::bail!(
769            "unknown tool {:?}; valid: {}",
770            args.tool,
771            valid_tool_names()
772        );
773    }
774
775    let mut removed = 0usize;
776
777    for tool in &tools {
778        let Some(cfg_path) = (tool.config_path_fn)() else {
779            continue;
780        };
781        if tool.format == ConfigFormat::Manual {
782            eprintln!(
783                "  {} — remove the asd MCP server manually from {}",
784                tool.name,
785                cfg_path.display()
786            );
787            continue;
788        }
789        if !cfg_path.exists() {
790            continue;
791        }
792
793        let was_present = match tool.format {
794            ConfigFormat::Json | ConfigFormat::Jsonc => {
795                let mut cfg = read_config(&cfg_path, tool.format)?;
796                let present = remove_server(&mut cfg, tool.servers_key);
797                if present {
798                    write_config(&cfg_path, &cfg)?;
799                }
800                present
801            }
802            ConfigFormat::Toml => toml_remove_server(&cfg_path, tool.servers_key)?,
803            ConfigFormat::Manual => unreachable!("handled above"),
804        };
805
806        if was_present {
807            eprintln!("  {} removed  {}", tool.name, cfg_path.display());
808            removed += 1;
809        } else {
810            eprintln!("  {} not registered  (skipped)", tool.name);
811        }
812    }
813
814    if removed > 0 {
815        eprintln!();
816        eprintln!("Restart your agent tool(s) to deactivate the MCP server.");
817    }
818
819    Ok(())
820}
821
822// ---------------------------------------------------------------------------
823// status
824// ---------------------------------------------------------------------------
825
826fn status() -> Result<()> {
827    let bin = find_asd_mcp();
828
829    eprintln!(
830        "asd-mcp binary: {}",
831        match &bin {
832            Some(p) => p.to_string_lossy().into_owned(),
833            None => "NOT FOUND — install with: cargo install --path crates/agentstatedeveloper-mcp"
834                .to_string(),
835        }
836    );
837    eprintln!();
838
839    for tool in TOOLS {
840        let Some(cfg_path) = (tool.config_path_fn)() else {
841            eprintln!("  ✗ {}  (unsupported platform)", tool.name);
842            continue;
843        };
844
845        if tool.format == ConfigFormat::Manual {
846            eprintln!("  ⚙ {}  manual setup ({})", tool.name, cfg_path.display());
847            continue;
848        }
849
850        if !cfg_path.exists() {
851            eprintln!(
852                "  ✗ {}  config not found: {}",
853                tool.name,
854                cfg_path.display()
855            );
856            continue;
857        }
858
859        match read_registered_entry(&cfg_path, tool.format, tool.servers_key) {
860            Some((cmd, db)) => {
861                eprintln!("  ✓ {}  command={}  ASD_DB={}", tool.name, cmd, db);
862            }
863            None => {
864                eprintln!(
865                    "  ✗ {}  not registered  ({})",
866                    tool.name,
867                    cfg_path.display()
868                );
869            }
870        }
871    }
872
873    Ok(())
874}
875
876// ---------------------------------------------------------------------------
877// instructions — inject a managed ASD usage block into agent instruction files
878// ---------------------------------------------------------------------------
879
880// Marker aligns with the shared engine's rendered block (`<!-- asd:begin -->`),
881// so `render_always_on` output IS the managed block. `upsert_block` keys on
882// these. (Migration note: the older descriptive marker is not recognized; the
883// `asd mcp instructions` feature is new enough that no deployed block relies on
884// it.)
885const BLOCK_BEGIN: &str = "<!-- asd:begin -->";
886const BLOCK_END: &str = "<!-- asd:end -->";
887
888/// The managed instruction block — rendered from ASD's single onboarding
889/// `SkillSpec` via the shared engine (suite-onboarding t-007), so the always-on
890/// block and the installed `SKILL.md` train the agent from one source.
891fn instruction_body() -> String {
892    crate::skillgen::render_always_on(&crate::commands::skill::asd_skill_spec())
893}
894
895/// Insert or replace the managed block in `content`. Returns the new content.
896/// When `remove` is true, the block is stripped instead.
897fn upsert_block(content: &str, block: &str, remove: bool) -> String {
898    if let (Some(start), Some(end_idx)) = (content.find(BLOCK_BEGIN), content.find(BLOCK_END)) {
899        // Replace from BEGIN through END (plus the marker text).
900        let end = end_idx + BLOCK_END.len();
901        let before = content[..start].trim_end_matches('\n');
902        let after = content[end..].trim_start_matches('\n');
903        let mut out = String::new();
904        if !before.is_empty() {
905            out.push_str(before);
906            out.push_str("\n\n");
907        }
908        if !remove {
909            out.push_str(block);
910        }
911        if !after.is_empty() {
912            if !remove {
913                out.push('\n');
914            }
915            out.push_str(after);
916            out.push('\n');
917        }
918        out
919    } else if remove {
920        content.to_string()
921    } else if content.trim().is_empty() {
922        block.to_string()
923    } else {
924        format!("{}\n\n{block}", content.trim_end_matches('\n'))
925    }
926}
927
928fn instructions(args: InstructionsArgs) -> Result<()> {
929    let files = if args.file.is_empty() {
930        vec!["AGENTS.md".to_string(), "CLAUDE.md".to_string()]
931    } else {
932        args.file
933    };
934    let block = instruction_body();
935    let cwd = std::env::current_dir().context("cannot determine current directory")?;
936
937    for rel in &files {
938        let path = cwd.join(rel);
939        let existing = std::fs::read_to_string(&path).unwrap_or_default();
940        let had_block = existing.contains(BLOCK_BEGIN);
941        let updated = upsert_block(&existing, &block, args.remove);
942
943        if updated == existing {
944            eprintln!("  {rel} — no change");
945            continue;
946        }
947        if let Some(parent) = path.parent() {
948            std::fs::create_dir_all(parent)
949                .with_context(|| format!("create dirs for {}", path.display()))?;
950        }
951        std::fs::write(&path, &updated).with_context(|| format!("write {}", path.display()))?;
952        let verb = if args.remove {
953            "removed ASD block from"
954        } else if had_block {
955            "refreshed ASD block in"
956        } else {
957            "added ASD block to"
958        };
959        eprintln!("  {verb} {rel}");
960    }
961
962    if args.hooks {
963        let path = cwd.join(".claude/settings.json");
964        let mut settings = read_config(&path, ConfigFormat::Json)?;
965        if upsert_claude_hook(&mut settings, args.remove) {
966            write_config(&path, &settings)?;
967            let verb = if args.remove { "removed" } else { "added" };
968            eprintln!("  {verb} ASD PreToolUse hook in .claude/settings.json");
969        } else {
970            eprintln!("  .claude/settings.json — no change");
971        }
972    }
973    Ok(())
974}
975
976/// Substring identifying ASD's hook entry (for idempotency / removal).
977const HOOK_MARKER: &str = "asd search / asd context-for";
978
979fn asd_hook_entry() -> serde_json::Value {
980    serde_json::json!({
981        "matcher": "Grep|Glob",
982        "hooks": [{
983            "type": "command",
984            "command": "echo 'ASD indexes this repo — prefer asd search / asd context-for for \
985                        structured answers, often cheaper than raw grep/glob.'"
986        }]
987    })
988}
989
990/// Add or remove ASD's non-blocking PreToolUse hook in a Claude settings value.
991/// Returns whether anything changed.
992fn upsert_claude_hook(settings: &mut serde_json::Value, remove: bool) -> bool {
993    let Some(obj) = settings.as_object_mut() else {
994        return false;
995    };
996    let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
997    let Some(hooks) = hooks.as_object_mut() else {
998        return false;
999    };
1000    let pre = hooks
1001        .entry("PreToolUse")
1002        .or_insert_with(|| serde_json::json!([]));
1003    let Some(arr) = pre.as_array_mut() else {
1004        return false;
1005    };
1006    let existing = arr.iter().position(|e| e.to_string().contains(HOOK_MARKER));
1007    if remove {
1008        if let Some(i) = existing {
1009            arr.remove(i);
1010            return true;
1011        }
1012        return false;
1013    }
1014    if existing.is_some() {
1015        return false;
1016    }
1017    arr.push(asd_hook_entry());
1018    true
1019}
1020
1021// ---------------------------------------------------------------------------
1022// Tests
1023// ---------------------------------------------------------------------------
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028
1029    #[test]
1030    fn upsert_block_appends_to_existing_content() {
1031        let block = instruction_body();
1032        let out = upsert_block("# My project\n\nNotes here.\n", &block, false);
1033        assert!(out.starts_with("# My project"));
1034        assert!(out.contains(BLOCK_BEGIN) && out.contains(BLOCK_END));
1035        assert!(out.contains("asd context-for"));
1036    }
1037
1038    #[test]
1039    fn upsert_block_is_idempotent() {
1040        let block = instruction_body();
1041        let once = upsert_block("# P\n", &block, false);
1042        let twice = upsert_block(&once, &block, false);
1043        assert_eq!(once, twice, "re-running must not duplicate the block");
1044        assert_eq!(twice.matches(BLOCK_BEGIN).count(), 1);
1045    }
1046
1047    #[test]
1048    fn upsert_block_refreshes_in_place() {
1049        // An old/edited block between markers is replaced, surrounding text kept.
1050        let stale = format!("# P\n\n{BLOCK_BEGIN}\nOLD\n{BLOCK_END}\n\n## After\n");
1051        let out = upsert_block(&stale, &instruction_body(), false);
1052        assert!(out.contains("# P") && out.contains("## After"));
1053        assert!(!out.contains("OLD"));
1054        assert!(out.contains("asd architecture"));
1055        assert_eq!(out.matches(BLOCK_BEGIN).count(), 1);
1056    }
1057
1058    #[test]
1059    fn upsert_block_remove_strips_it() {
1060        let with = upsert_block("# P\n\nbody\n", &instruction_body(), false);
1061        let without = upsert_block(&with, &instruction_body(), true);
1062        assert!(!without.contains(BLOCK_BEGIN));
1063        assert!(without.contains("# P") && without.contains("body"));
1064    }
1065
1066    #[test]
1067    fn claude_hook_add_idempotent_and_preserves_other_hooks() {
1068        let mut s = serde_json::json!({
1069            "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [] } ] },
1070            "model": "x"
1071        });
1072        assert!(upsert_claude_hook(&mut s, false), "first add changes");
1073        assert!(!upsert_claude_hook(&mut s, false), "second add is a no-op");
1074        let arr = s["hooks"]["PreToolUse"].as_array().unwrap();
1075        assert_eq!(arr.len(), 2, "ours added alongside the existing Bash hook");
1076        assert_eq!(s["model"], "x", "unrelated settings preserved");
1077    }
1078
1079    #[test]
1080    fn claude_hook_remove() {
1081        let mut s = serde_json::json!({});
1082        upsert_claude_hook(&mut s, false);
1083        assert!(upsert_claude_hook(&mut s, true), "remove changes");
1084        assert!(s["hooks"]["PreToolUse"].as_array().unwrap().is_empty());
1085        assert!(
1086            !upsert_claude_hook(&mut s, true),
1087            "second remove is a no-op"
1088        );
1089    }
1090
1091    #[test]
1092    fn tool_names_are_unique() {
1093        let mut names: Vec<&str> = TOOLS.iter().map(|t| t.name).collect();
1094        let count = names.len();
1095        names.sort_unstable();
1096        names.dedup();
1097        assert_eq!(names.len(), count, "duplicate tool name in TOOLS");
1098    }
1099
1100    #[test]
1101    fn registry_includes_all_tools() {
1102        let names: Vec<&str> = TOOLS.iter().map(|t| t.name).collect();
1103        for expected in [
1104            "claude-code",
1105            "claude-desktop",
1106            "cursor",
1107            "gemini-cli",
1108            "windsurf",
1109            "zed",
1110            "vscode",
1111            "cline",
1112            "codex-cli",
1113            "kilo-code",
1114            "antigravity",
1115            "aider",
1116        ] {
1117            assert!(names.contains(&expected), "TOOLS missing {expected}");
1118        }
1119        // valid_tool_names() is derived from TOOLS, so it must list them too.
1120        let listed = valid_tool_names();
1121        assert!(listed.contains("zed") && listed.contains("vscode") && listed.contains("cline"));
1122        assert!(listed.contains("codex-cli") && listed.contains("kilo-code"));
1123        assert!(listed.contains("antigravity") && listed.contains("aider"));
1124    }
1125
1126    #[test]
1127    fn jsonc_comments_stripped_but_strings_preserved() {
1128        let src =
1129            "{\n  // line comment\n  \"url\": \"http://x/a\", /* block */\n  \"k\": \"a//b\"\n}";
1130        let parsed: serde_json::Value = serde_json::from_str(&strip_jsonc_comments(src)).unwrap();
1131        // The "//" inside string values must survive.
1132        assert_eq!(parsed["url"], "http://x/a");
1133        assert_eq!(parsed["k"], "a//b");
1134    }
1135
1136    #[test]
1137    fn jsonc_round_trip_preserves_sibling_servers() {
1138        // A commented kilo.jsonc with an existing server: read (strip), upsert, write.
1139        let dir = std::env::temp_dir().join(format!("asd_mcp_jsonc_{}", std::process::id()));
1140        std::fs::create_dir_all(&dir).unwrap();
1141        let path = dir.join("kilo.jsonc");
1142        std::fs::write(
1143            &path,
1144            "{\n  // my config\n  \"mcpServers\": { \"other\": { \"command\": \"x\" } }\n}",
1145        )
1146        .unwrap();
1147        let mut cfg = read_config(&path, ConfigFormat::Jsonc).unwrap();
1148        upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap();
1149        write_config(&path, &cfg).unwrap();
1150        // Re-read: both servers present (comments are gone, which is expected).
1151        let back = read_config(&path, ConfigFormat::Jsonc).unwrap();
1152        assert!(back["mcpServers"]["other"].is_object());
1153        assert!(back["mcpServers"]["asd"].is_object());
1154        let _ = std::fs::remove_dir_all(&dir);
1155    }
1156
1157    fn std_entry() -> serde_json::Value {
1158        build_entry(
1159            EntryStyle::Standard,
1160            Path::new("/bin/asd-mcp"),
1161            Some(Path::new("/db")),
1162        )
1163    }
1164
1165    #[test]
1166    fn standard_entry_has_expected_shape() {
1167        let entry = build_entry(
1168            EntryStyle::Standard,
1169            Path::new("/usr/local/bin/asd-mcp"),
1170            Some(Path::new("/repo/.asd-state.db")),
1171        );
1172        assert_eq!(entry["command"], "/usr/local/bin/asd-mcp");
1173        assert!(entry["args"].as_array().unwrap().is_empty());
1174        assert_eq!(entry["env"]["ASD_DB"], "/repo/.asd-state.db");
1175        // No style markers on the standard entry.
1176        assert!(entry.get("source").is_none() && entry.get("type").is_none());
1177    }
1178
1179    #[test]
1180    fn follow_active_entry_omits_asd_db() {
1181        // t-003: `--follow-active` (db_path = None) writes an empty env so the
1182        // server resolves the active repo from the registry instead.
1183        let entry = build_entry(EntryStyle::Standard, Path::new("/bin/asd-mcp"), None);
1184        assert!(
1185            entry["env"].as_object().unwrap().is_empty(),
1186            "no ASD_DB pinned when following the active repo"
1187        );
1188        assert_eq!(entry["command"], "/bin/asd-mcp");
1189    }
1190
1191    #[test]
1192    fn divergent_entry_styles_add_their_marker() {
1193        let zed = build_entry(
1194            EntryStyle::ZedCustom,
1195            Path::new("/bin/asd-mcp"),
1196            Some(Path::new("/db")),
1197        );
1198        assert_eq!(zed["source"], "custom");
1199        assert_eq!(zed["command"], "/bin/asd-mcp"); // standard body still present
1200
1201        let vscode = build_entry(
1202            EntryStyle::VsCodeStdio,
1203            Path::new("/bin/asd-mcp"),
1204            Some(Path::new("/db")),
1205        );
1206        assert_eq!(vscode["type"], "stdio");
1207        assert_eq!(vscode["env"]["ASD_DB"], "/db");
1208    }
1209
1210    #[test]
1211    fn upsert_uses_the_tools_servers_key() {
1212        // Zed nests under context_servers, not mcpServers.
1213        let mut cfg = serde_json::json!({});
1214        let entry = build_entry(
1215            EntryStyle::ZedCustom,
1216            Path::new("/bin/asd-mcp"),
1217            Some(Path::new("/db")),
1218        );
1219        let already = upsert_server(&mut cfg, "context_servers", &entry).unwrap();
1220        assert!(!already);
1221        assert_eq!(cfg["context_servers"]["asd"]["source"], "custom");
1222        assert!(cfg.get("mcpServers").is_none(), "must not touch mcpServers");
1223    }
1224
1225    #[test]
1226    fn upsert_into_empty_config_creates_entry() {
1227        let mut cfg = serde_json::json!({});
1228        let already = upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap();
1229        assert!(!already, "fresh insert should not report already-present");
1230        assert_eq!(cfg["mcpServers"]["asd"]["command"], "/bin/asd-mcp");
1231    }
1232
1233    #[test]
1234    fn upsert_is_idempotent_and_reports_update() {
1235        let mut cfg = serde_json::json!({});
1236        assert!(!upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap());
1237        // Second insert overwrites and reports the prior presence.
1238        assert!(upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap());
1239        assert_eq!(cfg["mcpServers"].as_object().unwrap().len(), 1);
1240    }
1241
1242    #[test]
1243    fn upsert_preserves_sibling_servers() {
1244        let mut cfg = serde_json::json!({
1245            "servers": { "other": { "command": "x" } },
1246            "someOtherKey": 1
1247        });
1248        upsert_server(&mut cfg, "servers", &std_entry()).unwrap();
1249        assert_eq!(cfg["servers"]["other"]["command"], "x");
1250        assert_eq!(cfg["someOtherKey"], 1);
1251        assert!(cfg["servers"]["asd"].is_object());
1252    }
1253
1254    #[test]
1255    fn remove_returns_false_when_absent_true_when_present() {
1256        let mut cfg = serde_json::json!({});
1257        assert!(!remove_server(&mut cfg, "mcpServers"), "nothing to remove");
1258        upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap();
1259        assert!(
1260            remove_server(&mut cfg, "mcpServers"),
1261            "should remove the asd entry"
1262        );
1263        assert!(
1264            !remove_server(&mut cfg, "mcpServers"),
1265            "second remove is a no-op"
1266        );
1267    }
1268
1269    fn temp_toml(tag: &str) -> PathBuf {
1270        let dir = std::env::temp_dir().join(format!("asd_mcp_test_{}_{}", std::process::id(), tag));
1271        std::fs::create_dir_all(&dir).unwrap();
1272        dir.join("config.toml")
1273    }
1274
1275    #[test]
1276    fn toml_upsert_preserves_comments_and_siblings() {
1277        let path = temp_toml("preserve");
1278        std::fs::write(
1279            &path,
1280            "# hand-written codex config\nmodel = \"o3\"\n\n[mcp_servers.other]\ncommand = \"x\"\n",
1281        )
1282        .unwrap();
1283
1284        let already = toml_upsert_server(
1285            &path,
1286            "mcp_servers",
1287            Path::new("/bin/asd-mcp"),
1288            Some(Path::new("/db")),
1289        )
1290        .unwrap();
1291        assert!(!already, "fresh insert");
1292
1293        let txt = std::fs::read_to_string(&path).unwrap();
1294        assert!(txt.contains("# hand-written codex config"), "comment kept");
1295        assert!(txt.contains("model = \"o3\""), "sibling setting kept");
1296        assert!(txt.contains("[mcp_servers.other]"), "sibling server kept");
1297        assert!(txt.contains("[mcp_servers.asd]"), "asd table written");
1298        assert!(txt.contains("ASD_DB"), "env written");
1299
1300        // Read-only accessor sees the registration.
1301        let (cmd, db) = read_registered_entry(&path, ConfigFormat::Toml, "mcp_servers").unwrap();
1302        assert_eq!(cmd, "/bin/asd-mcp");
1303        assert_eq!(db, "/db");
1304
1305        // Idempotent: second upsert reports already-present.
1306        assert!(
1307            toml_upsert_server(
1308                &path,
1309                "mcp_servers",
1310                Path::new("/bin/asd-mcp"),
1311                Some(Path::new("/db"))
1312            )
1313            .unwrap()
1314        );
1315
1316        // Remove leaves the rest of the document intact.
1317        assert!(toml_remove_server(&path, "mcp_servers").unwrap());
1318        let txt2 = std::fs::read_to_string(&path).unwrap();
1319        assert!(!txt2.contains("[mcp_servers.asd]"), "asd removed");
1320        assert!(
1321            txt2.contains("# hand-written codex config"),
1322            "comment survives removal"
1323        );
1324        assert!(
1325            txt2.contains("[mcp_servers.other]"),
1326            "sibling survives removal"
1327        );
1328        assert!(
1329            !toml_remove_server(&path, "mcp_servers").unwrap(),
1330            "second remove no-op"
1331        );
1332
1333        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1334    }
1335
1336    #[test]
1337    fn toml_upsert_creates_file_when_absent() {
1338        let path = temp_toml("create");
1339        let _ = std::fs::remove_file(&path);
1340        toml_upsert_server(
1341            &path,
1342            "mcp_servers",
1343            Path::new("/bin/asd-mcp"),
1344            Some(Path::new("/db")),
1345        )
1346        .unwrap();
1347        let (cmd, _db) = read_registered_entry(&path, ConfigFormat::Toml, "mcp_servers").unwrap();
1348        assert_eq!(cmd, "/bin/asd-mcp");
1349        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1350    }
1351}