1use 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
43pub 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
84struct 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 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
127fn 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 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
153fn 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
245fn 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
281fn project_key(dir: &Path) -> String {
284 dir.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
285}
286
287fn 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
321fn 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}