Skip to main content

agentmux_mcp/
doctor.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `agentmux-mcp doctor [--heal]`: why this agent's AgentMux tools aren't
5//! connected, run from its shell with no MCP session
6//! (docs/specs/SPEC_AGENT_MCP_SELF_HEAL_2026_10_10.md ยง7). It checks what a
7//! person would check by hand: that this binary starts and answers MCP, also
8//! through Claude Code's shell prefix the way the CLI starts it; the agent's
9//! `.mcp.json`; what srv saw and tried; and Claude Code's own log for the
10//! server. Read-only, except `--heal`, which asks srv to run the repair for
11//! the caller's own pane.
12
13use std::io::{BufRead, BufReader, Write};
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16use std::time::Duration;
17
18use serde_json::{json, Value};
19
20const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
21const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"agentmux-mcp-doctor","version":"0"}}}"#;
22
23#[derive(Default)]
24struct Report {
25    lines: Vec<(&'static str, String)>,
26}
27
28impl Report {
29    fn ok(&mut self, text: impl Into<String>) {
30        self.lines.push(("ok  ", text.into()));
31    }
32    fn fail(&mut self, text: impl Into<String>) {
33        self.lines.push(("FAIL", text.into()));
34    }
35    fn info(&mut self, text: impl Into<String>) {
36        self.lines.push(("info", text.into()));
37    }
38    fn failed(&self) -> bool {
39        self.lines.iter().any(|(k, _)| *k == "FAIL")
40    }
41}
42
43/// Run the checks, print the report, and return the exit code: 1 when a
44/// check failed.
45pub async fn run(args: Vec<String>) -> i32 {
46    let heal = args.iter().any(|a| a == "--heal");
47    let mut report = Report::default();
48    let workdir = std::env::var("AGENTMUX_AGENT_WORKDIR")
49        .ok()
50        .filter(|w| !w.is_empty())
51        .map(PathBuf::from)
52        .or_else(|| std::env::current_dir().ok())
53        .unwrap_or_default();
54
55    report.ok(format!(
56        "agentmux-mcp {} ({})",
57        env!("CARGO_PKG_VERSION"),
58        std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default()
59    ));
60    let entry = check_config(&mut report, &workdir);
61    check_handshakes(&mut report, entry.as_ref());
62    let srv = Srv::from_env();
63    match &srv {
64        Some(srv) => check_srv(&mut report, srv).await,
65        None => report.fail("AGENTMUX_LOCAL_URL, AGENTMUX_AUTH_KEY, AGENTMUX_AGENT_TOKEN or AGENTMUX_AGENT_SLUG is not set: run this from the agent's own shell"),
66    }
67    check_cli_log(&mut report, &workdir);
68    if heal {
69        match &srv {
70            Some(srv) => request_heal(&mut report, srv).await,
71            None => report.fail("--heal needs srv (see above)"),
72        }
73    }
74
75    for (kind, text) in &report.lines {
76        println!("{kind} {text}");
77    }
78    if !heal && report.failed() {
79        println!("\nRun `agentmux-mcp doctor --heal` to ask AgentMux to repair it, and tell the operator.");
80    }
81    i32::from(report.failed())
82}
83
84/// The `agentmux` entry in the agent's `.mcp.json`: its command and args.
85struct Entry {
86    command: String,
87    args: Vec<String>,
88}
89
90fn check_config(report: &mut Report, workdir: &Path) -> Option<Entry> {
91    let path = workdir.join(".mcp.json");
92    let parsed = match std::fs::read_to_string(&path).map(|t| serde_json::from_str::<Value>(&t)) {
93        Ok(Ok(v)) => v,
94        Ok(Err(e)) => {
95            report.fail(format!("{} is not valid JSON: {e}", path.display()));
96            return None;
97        }
98        Err(e) => {
99            report.fail(format!("can't read {}: {e}", path.display()));
100            return None;
101        }
102    };
103    let Some(server) = parsed.pointer("/mcpServers/agentmux") else {
104        report.fail(format!("{} has no mcpServers.agentmux entry", path.display()));
105        return None;
106    };
107    let command = server.get("command").and_then(Value::as_str).unwrap_or_default().to_string();
108    if command.is_empty() {
109        report.fail("the agentmux entry in .mcp.json has no command");
110        return None;
111    }
112    let args: Vec<String> = server
113        .get("args")
114        .and_then(Value::as_array)
115        .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
116        .unwrap_or_default();
117    // Present or not, never their values.
118    let key_set = |k: &str| server.pointer(&format!("/env/{k}")).and_then(Value::as_str).is_some_and(|v| !v.is_empty());
119    if key_set("AGENTMUX_JEKT_KEY") {
120        report.ok(format!(".mcp.json has the agentmux entry ({command}) and its signing key"));
121    } else {
122        report.fail(".mcp.json's agentmux entry has no AGENTMUX_JEKT_KEY: messages can't be signed; restart the agent to get one");
123    }
124    Some(Entry { command, args })
125}
126
127/// Start the server the way the CLI does, and without the prefix too, so a
128/// failure says which part broke.
129fn check_handshakes(report: &mut Report, entry: Option<&Entry>) {
130    let (command, args) = match entry {
131        Some(e) => (e.command.clone(), e.args.clone()),
132        None => ("agentmux-mcp".to_string(), Vec::new()),
133    };
134    match handshake(Command::new(&command).args(&args)) {
135        Ok(version) => report.ok(format!("`{command}` starts and answers MCP (server {version})")),
136        Err(e) => report.fail(format!("`{command}` doesn't answer MCP: {e}")),
137    }
138    let Some(prefix) = std::env::var("CLAUDE_CODE_SHELL_PREFIX").ok().filter(|p| !p.is_empty()) else {
139        report.info("no CLAUDE_CODE_SHELL_PREFIX: the CLI starts the server directly");
140        return;
141    };
142    // The CLI passes the prefix one argument: the command line, joined.
143    let joined = std::iter::once(command.as_str()).chain(args.iter().map(String::as_str)).collect::<Vec<_>>().join(" ");
144    let name = Path::new(&prefix).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or(prefix.clone());
145    match handshake(Command::new(&prefix).arg(&joined)) {
146        Ok(_) => report.ok(format!("it starts through the shell prefix ({name}), as the CLI starts it")),
147        Err(e) => report.fail(format!(
148            "it does NOT start through the shell prefix ({name}), which is how the CLI starts it: {e}"
149        )),
150    }
151}
152
153/// Send `initialize` and wait for the answer: the server's version, or why not.
154fn handshake(cmd: &mut Command) -> Result<String, String> {
155    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
156    #[cfg(windows)]
157    {
158        use agentmux_common::win32::NoWindow;
159        cmd.no_window();
160    }
161    let mut child = cmd.spawn().map_err(|e| format!("can't start it: {e}"))?;
162    let mut stdin = child.stdin.take().ok_or("no stdin")?;
163    let stdout = child.stdout.take().ok_or("no stdout")?;
164    let stderr = child.stderr.take().ok_or("no stderr")?;
165    let _ = writeln!(stdin, "{INITIALIZE}");
166    let (tx, rx) = std::sync::mpsc::channel();
167    std::thread::spawn(move || {
168        let mut line = String::new();
169        let _ = BufReader::new(stdout).read_line(&mut line);
170        let _ = tx.send(line);
171    });
172    let answer = rx.recv_timeout(HANDSHAKE_TIMEOUT);
173    drop(stdin);
174    let _ = child.kill();
175    let _ = child.wait();
176    let first_stderr_line = || {
177        let mut text = String::new();
178        let _ = BufReader::new(stderr).read_line(&mut text);
179        text.trim().to_string()
180    };
181    match answer {
182        Ok(line) if !line.trim().is_empty() => match serde_json::from_str::<Value>(&line) {
183            Ok(v) => v
184                .pointer("/result/serverInfo/version")
185                .and_then(Value::as_str)
186                .map(str::to_string)
187                .ok_or_else(|| format!("unexpected answer: {}", line.trim())),
188            Err(_) => Err(format!("not JSON-RPC on stdout: {}", line.trim())),
189        },
190        Ok(_) => {
191            let err = first_stderr_line();
192            Err(if err.is_empty() { "it exited without answering".to_string() } else { format!("it exited: {err}") })
193        }
194        Err(_) => Err(format!("no answer in {} s", HANDSHAKE_TIMEOUT.as_secs())),
195    }
196}
197
198struct Srv {
199    client: reqwest::Client,
200    url: String,
201    auth_key: String,
202    token: String,
203    slug: String,
204}
205
206impl Srv {
207    fn from_env() -> Option<Self> {
208        let var = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
209        Some(Self {
210            client: reqwest::Client::builder().timeout(Duration::from_secs(10)).build().ok()?,
211            url: var("AGENTMUX_LOCAL_URL")?,
212            auth_key: var("AGENTMUX_AUTH_KEY")?,
213            token: var("AGENTMUX_AGENT_TOKEN")?,
214            slug: var("AGENTMUX_AGENT_SLUG")?,
215        })
216    }
217
218    async fn post(&self, path: &str) -> Result<(u16, Value), String> {
219        let resp = self
220            .client
221            .post(format!("{}{path}", self.url.trim_end_matches('/')))
222            .header(agentmux_common::AUTH_KEY_HEADER, &self.auth_key)
223            .header("X-Agent-Token", self.token.trim())
224            .json(&json!({ "agent": self.slug }))
225            .send()
226            .await
227            .map_err(|e| e.to_string())?;
228        let status = resp.status().as_u16();
229        Ok((status, resp.json().await.unwrap_or(Value::Null)))
230    }
231}
232
233async fn check_srv(report: &mut Report, srv: &Srv) {
234    match srv.post("/api/v1/agent/self/mcp-health").await {
235        Ok((200, body)) => {
236            report.ok(format!("AgentMux (srv) answers at {}", srv.url));
237            report.info(srv_health_line(&body));
238        }
239        Ok((404, _)) => report.info("this AgentMux is older than this doctor: it can't say what it saw, or repair"),
240        Ok((code, body)) => report.fail(format!("srv refused ({code}): {}", body.get("error").and_then(Value::as_str).unwrap_or(""))),
241        Err(e) => report.fail(format!("can't reach srv at {}: {e}", srv.url)),
242    }
243}
244
245/// One line on what srv saw and did, from its `mcp-health` answer.
246fn srv_health_line(body: &Value) -> String {
247    let auto = if body.get("auto").and_then(Value::as_bool) == Some(false) { " (automatic repair is off)" } else { "" };
248    let Some(h) = body.get("health").filter(|h| !h.is_null()) else {
249        return format!("srv hasn't seen this agent's MCP status yet{auto}");
250    };
251    let get = |k: &str| h.get(k).and_then(Value::as_str).unwrap_or("?");
252    let mut line = format!("srv saw the server {}; repair: {}", get("status"), get("phase"));
253    if h.get("safe_profile").and_then(Value::as_bool) == Some(true) {
254        line.push_str("; running in the safe profile (no shell prefix)");
255    }
256    if let Some(e) = h.get("last_error").and_then(Value::as_str) {
257        line.push_str(&format!("; last error: {e}"));
258    }
259    line + auto
260}
261
262async fn request_heal(report: &mut Report, srv: &Srv) {
263    match srv.post("/api/v1/agent/self/mcp-heal").await {
264        Ok((202, _)) => report.ok("repair started: AgentMux is reconnecting the server; the pane shows how it goes"),
265        Ok((404, _)) => report.fail("this AgentMux is older than this doctor and can't repair: ask the operator to restart the agent"),
266        Ok((200, body)) => {
267            let status = body.get("status").and_then(Value::as_str).unwrap_or("?");
268            report.info(match status {
269                "connected" => "nothing to repair: srv saw the server connected".to_string(),
270                "already_healing" => "a repair is already under way".to_string(),
271                "no_status_yet" => "srv hasn't seen the server's status yet; try again once the agent has started".to_string(),
272                "no_process" => "the agent's CLI isn't running".to_string(),
273                other => format!("srv: {other}"),
274            });
275        }
276        Ok((code, body)) => report.fail(format!("srv refused the repair ({code}): {}", body.get("error").and_then(Value::as_str).unwrap_or(""))),
277        Err(e) => report.fail(format!("can't reach srv: {e}")),
278    }
279}
280
281/// Claude Code's folder name for a project: its path with every character
282/// that isn't a letter or digit made `-`.
283fn project_key(dir: &Path) -> String {
284    dir.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
285}
286
287/// Where Claude Code keeps its per-project caches (the `env-paths` cache
288/// folder for `claude-cli`). Its own layout, so a miss is reported, not an
289/// error.
290fn cli_cache_dir() -> Option<PathBuf> {
291    let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
292    if cfg!(windows) {
293        Some(var("LOCALAPPDATA")?.join("claude-cli-nodejs").join("Cache"))
294    } else if cfg!(target_os = "macos") {
295        Some(var("HOME")?.join("Library").join("Caches").join("claude-cli-nodejs"))
296    } else {
297        let base = var("XDG_CACHE_HOME").or_else(|| var("HOME").map(|h| h.join(".cache")))?;
298        Some(base.join("claude-cli-nodejs"))
299    }
300}
301
302fn check_cli_log(report: &mut Report, workdir: &Path) {
303    let Some(dir) = cli_cache_dir().map(|c| c.join(project_key(workdir)).join("mcp-logs-agentmux")) else {
304        return;
305    };
306    let newest = std::fs::read_dir(&dir).ok().and_then(|entries| {
307        entries
308            .flatten()
309            .filter(|e| e.path().extension().is_some_and(|x| x == "jsonl"))
310            .max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok())
311    });
312    let Some(file) = newest else {
313        report.info(format!("no Claude Code MCP log for the server in {}", dir.display()));
314        return;
315    };
316    let text = std::fs::read_to_string(file.path()).unwrap_or_default();
317    let name = file.file_name().to_string_lossy().into_owned();
318    report.info(format!("Claude Code's latest log for the server ({name}): {}", log_outcome(&text)));
319}
320
321/// What the CLI's log says happened: the last attempt's error, with what the
322/// server printed on stderr (the useful half), or that it connected.
323fn log_outcome(text: &str) -> String {
324    let flat = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
325    let mut outcome = "no connection attempt recorded".to_string();
326    let mut stderr: Option<String> = None;
327    for line in text.lines() {
328        let Ok(v) = serde_json::from_str::<Value>(line) else { continue };
329        let debug = v.get("debug").and_then(Value::as_str).unwrap_or_default();
330        if debug.starts_with("Starting connection") {
331            stderr = None;
332        } else if debug.starts_with("Successfully connected") {
333            outcome = "connected".to_string();
334        } else if let Some(e) = v.get("error").and_then(Value::as_str) {
335            match e.strip_prefix("Server stderr:") {
336                Some(rest) => stderr = Some(flat(rest)),
337                None => {
338                    outcome = match &stderr {
339                        Some(s) => format!("error: {} (server stderr: {s})", flat(e)),
340                        None => format!("error: {}", flat(e)),
341                    }
342                }
343            }
344        }
345    }
346    outcome
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn the_project_key_is_claude_codes_folder_name() {
355        assert_eq!(
356            project_key(Path::new(r"C:\Users\u\.agentmux\agents\agenty-0629j")),
357            "C--Users-u--agentmux-agents-agenty-0629j"
358        );
359        assert_eq!(project_key(Path::new("/home/u/work")), "-home-u-work");
360    }
361
362    #[test]
363    fn the_log_outcome_is_the_last_thing_that_happened() {
364        let failed = r#"{"debug":"Starting connection with timeout of 30000ms"}
365{"error":"Server stderr: error: unrecognized subcommand 'agentmux-mcp'\n\nUsage: agentmux-bashwrap.exe <COMMAND>\n"}
366{"error":"Connection failed (CONNECTION_CLOSED): Connection closed"}"#;
367        assert_eq!(
368            log_outcome(failed),
369            "error: Connection failed (CONNECTION_CLOSED): Connection closed (server stderr: error: unrecognized \
370             subcommand 'agentmux-mcp' Usage: agentmux-bashwrap.exe <COMMAND>)"
371        );
372        let ok = r#"{"debug":"Starting connection with timeout of 30000ms"}
373{"debug":"Successfully connected (transport: stdio) in 39ms"}"#;
374        assert_eq!(log_outcome(ok), "connected");
375        assert_eq!(log_outcome(""), "no connection attempt recorded");
376    }
377
378    #[test]
379    fn the_srv_line_says_what_srv_saw() {
380        assert_eq!(srv_health_line(&json!({ "health": null, "auto": true })), "srv hasn't seen this agent's MCP status yet");
381        let body = json!({ "auto": false, "health": {
382            "status": "failed", "phase": "gave_up", "safe_profile": true, "last_error": "Connection closed" } });
383        assert_eq!(
384            srv_health_line(&body),
385            "srv saw the server failed; repair: gave_up; running in the safe profile (no shell prefix); \
386             last error: Connection closed (automatic repair is off)"
387        );
388    }
389
390    #[test]
391    fn a_failed_check_fails_the_run() {
392        let mut r = Report::default();
393        r.ok("a");
394        r.info("b");
395        assert!(!r.failed());
396        r.fail("c");
397        assert!(r.failed());
398    }
399}