1use crate::install::{
19 claude_mcp_file, cursor_mcp_file, default_db, entry_db, expand_platform, git_hooks_dir,
20 has_our_server, installed_shape, intercept_installed, is_disabled, is_our_hook_command,
21 line_runs_for_store, resolve_platform, resolve_scope, Externals, Platform, Scope, StoreRef,
22 AUTO_ARG, BRIEF_EVENT, GIT_HOOKS, HOOK_BEGIN, HOOK_EVENT, INTERCEPT_EVENT, TOUCH_EVENT,
23};
24use crate::CliError;
25use core_api::{GraphDb, GraphError, OpenOptions};
26use serde_json::Value as Js;
27use std::io::{BufRead, BufReader, Read as _, Write as _};
28use std::path::{Path, PathBuf};
29use std::process::{Command, Stdio};
30use std::time::{Duration, Instant};
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct DoctorOpts {
35 pub platform: Option<Platform>,
37 pub scope: Option<Scope>,
39}
40
41pub struct DoctorReport {
43 pub output: String,
45 pub had_fail: bool,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum Status {
51 Ok,
52 Skip,
54 Warn,
55 Fail,
56}
57
58impl Status {
59 fn word(self) -> &'static str {
60 match self {
61 Status::Ok => "ok",
62 Status::Skip => "skip",
63 Status::Warn => "warn",
64 Status::Fail => "fail",
65 }
66 }
67}
68
69struct Check {
72 status: Status,
73 name: &'static str,
74 message: String,
75 fix: Option<String>,
76}
77
78impl Check {
79 fn ok(name: &'static str, message: impl Into<String>) -> Self {
80 Check {
81 status: Status::Ok,
82 name,
83 message: message.into(),
84 fix: None,
85 }
86 }
87 fn skip(name: &'static str, message: impl Into<String>) -> Self {
88 Check {
89 status: Status::Skip,
90 name,
91 message: message.into(),
92 fix: None,
93 }
94 }
95 fn warn(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
96 Check {
97 status: Status::Warn,
98 name,
99 message: message.into(),
100 fix,
101 }
102 }
103 fn fail(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
104 Check {
105 status: Status::Fail,
106 name,
107 message: message.into(),
108 fix,
109 }
110 }
111 fn render(&self) -> String {
112 let mut line = format!(
113 "{:<4} {:<9} {}",
114 self.status.word(),
115 self.name,
116 self.message
117 );
118 if let Some(fix) = &self.fix {
119 line.push_str(&format!(" fix: {fix}"));
120 }
121 line.push('\n');
122 line
123 }
124}
125
126pub fn run_doctor(
128 project_root: &Path,
129 home: &Path,
130 opts: &DoctorOpts,
131) -> Result<DoctorReport, CliError> {
132 run_doctor_with(project_root, home, opts, &Externals::from_env())
133}
134
135pub fn run_doctor_with(
139 project_root: &Path,
140 home: &Path,
141 opts: &DoctorOpts,
142 ext: &Externals,
143) -> Result<DoctorReport, CliError> {
144 let (scope, _auto_scope) = resolve_scope(project_root, opts.scope);
145 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
146 let platforms = expand_platform(&resolved);
147
148 if is_disabled(project_root, home, scope, &platforms) {
152 return Ok(DoctorReport {
153 output: Check::warn("state", "disabled — enable with: mushroomdb enable", None)
154 .render(),
155 had_fail: false,
156 });
157 }
158
159 let (delivery, recorded_store) = installed_shape(project_root, home, scope, &platforms);
165 let mcp_delivery = delivery.wires_mcp();
166
167 let mut checks: Vec<Check> = Vec::new();
168 let mut primary: Option<(Platform, ConfigEntry)> = None;
169
170 for plat in &platforms {
173 if !mcp_delivery && matches!(plat, Platform::ClaudeCode) {
177 checks.push(Check::skip(
178 "config",
179 format!(
180 "delivery: {} — the skill teaches the binary, no MCP entry to check",
181 delivery.label()
182 ),
183 ));
184 continue;
185 }
186 match mcp_file_for(plat, project_root, home, scope) {
187 None => checks.push(Check::warn(
188 "config",
189 format!(
190 "{}'s configuration is owned by its own CLI — not checked here",
191 plat.label()
192 ),
193 None,
194 )),
195 Some(mcp_file) => match read_config_entry(&mcp_file, project_root, home) {
196 Ok(entry) => {
197 checks.push(Check::ok(
198 "config",
199 format!(
200 "{} — {} -> {}",
201 plat.label(),
202 mcp_file.display(),
203 entry.describe_store()
204 ),
205 ));
206 if primary.is_none() {
207 primary = Some((plat.clone(), entry));
208 }
209 }
210 Err(msg) => checks.push(Check::fail("config", msg, Some(install_fix(scope, plat)))),
211 },
212 }
213 }
214
215 let store: Option<StoreRef> = match &primary {
218 Some((_, entry)) => Some(entry.store.clone()),
219 None if !mcp_delivery => recorded_store,
220 None => None,
221 };
222
223 if let Some((_, entry)) = &primary {
226 if entry.command == "npx" {
227 checks.push(check_npx(entry, ext));
228 } else if let Some(check) = check_resolved_path(entry) {
229 checks.push(check);
230 }
231 }
232
233 match &store {
235 Some(store) => checks.extend(check_store_and_lock(store.path())),
236 None => checks.push(Check::fail(
237 "store",
238 no_store_message(mcp_delivery),
239 Some(install_fix_for_scope(scope)),
240 )),
241 }
242
243 if platforms.contains(&Platform::ClaudeCode) {
245 if let Some(store) = &store {
246 checks.push(check_hooks(project_root, home, scope, store));
247 if intercept_installed(project_root, home, scope, &platforms) {
251 checks.push(check_intercept(project_root, home, scope, store));
252 }
253 }
254 }
255
256 if scope == Scope::Project
259 && platforms
260 .iter()
261 .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor))
262 {
263 if let Some(store) = &store {
264 if let Some(check) = check_git_hooks(project_root, store) {
265 checks.push(check);
266 }
267 }
268 }
269
270 match &primary {
272 Some((_, entry)) => checks.push(check_handshake(entry)),
273 None if !mcp_delivery => checks.push(Check::skip(
275 "handshake",
276 format!("delivery: {} — no server to spawn", delivery.label()),
277 )),
278 None => checks.push(Check::fail(
279 "handshake",
280 "no usable config entry — nothing to spawn",
281 Some(install_fix_for_scope(scope)),
282 )),
283 }
284
285 if platforms.contains(&Platform::ClaudeCode) {
287 checks.push(check_scope_conflict(project_root, home, scope));
288 }
289
290 let had_fail = checks.iter().any(|c| c.status == Status::Fail);
291 let mut output = String::new();
292 for c in &checks {
293 output.push_str(&c.render());
294 }
295 Ok(DoctorReport { output, had_fail })
296}
297
298fn no_store_message(mcp_delivery: bool) -> &'static str {
301 if mcp_delivery {
302 "no usable config entry — cannot locate a database to check"
303 } else {
304 "no recorded SessionStart hook — cannot locate a database to check"
305 }
306}
307
308fn install_fix_for_scope(scope: Scope) -> String {
309 format!(
310 "mushroomdb install {}",
311 match scope {
312 Scope::Project => "--project",
313 Scope::User => "--user",
314 }
315 )
316}
317
318fn install_fix(scope: Scope, plat: &Platform) -> String {
319 format!(
320 "mushroomdb install --platform {} {}",
321 plat.label(),
322 match scope {
323 Scope::Project => "--project",
324 Scope::User => "--user",
325 }
326 )
327}
328
329fn mcp_file_for(
330 plat: &Platform,
331 project_root: &Path,
332 home: &Path,
333 scope: Scope,
334) -> Option<PathBuf> {
335 match plat {
336 Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
337 Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
338 Platform::Codex | Platform::All => None,
339 }
340}
341
342struct ConfigEntry {
348 store: StoreRef,
350 command: String,
351 args: Vec<String>,
352}
353
354impl ConfigEntry {
355 fn describe_store(&self) -> String {
359 if self.store.is_auto() {
360 format!("{AUTO_ARG} -> {}", self.store.path().display())
361 } else {
362 self.store.path().display().to_string()
363 }
364 }
365}
366
367fn entry_store(arg: &str, project_root: &Path, home: &Path) -> StoreRef {
376 if arg == AUTO_ARG {
377 return StoreRef::auto(crate::resolve_auto_db(None, project_root, home));
378 }
379 let path = PathBuf::from(arg);
380 if path == default_db(Scope::Project, project_root, home) {
381 return StoreRef::pinned(path).also_auto();
382 }
383 StoreRef::pinned(path)
384}
385
386fn read_json(path: &Path) -> Result<Js, String> {
387 let raw = std::fs::read_to_string(path)
388 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
389 serde_json::from_str(&raw).map_err(|e| format!("invalid JSON in {}: {e}", path.display()))
390}
391
392fn read_config_entry(
393 mcp_file: &Path,
394 project_root: &Path,
395 home: &Path,
396) -> Result<ConfigEntry, String> {
397 if !mcp_file.exists() {
398 return Err(format!("{} does not exist", mcp_file.display()));
399 }
400 let root = read_json(mcp_file)?;
401 let entry = &root["mcpServers"]["mushroomdb"];
402 if entry.is_null() {
403 return Err(format!(
404 "no mcpServers.mushroomdb entry in {}",
405 mcp_file.display()
406 ));
407 }
408 let store = entry_store(
409 entry_db(entry).ok_or_else(|| {
410 format!(
411 "{}: mushroomdb entry has no `mcp <db>|--auto` argument",
412 mcp_file.display()
413 )
414 })?,
415 project_root,
416 home,
417 );
418 let command = entry["command"]
419 .as_str()
420 .ok_or_else(|| format!("{}: mushroomdb entry has no `command`", mcp_file.display()))?
421 .to_string();
422 let args = entry["args"]
423 .as_array()
424 .map(|a| {
425 a.iter()
426 .filter_map(|v| v.as_str().map(str::to_string))
427 .collect()
428 })
429 .unwrap_or_default();
430 Ok(ConfigEntry {
431 store,
432 command,
433 args,
434 })
435}
436
437const NPX_TIMEOUT: Duration = Duration::from_secs(60);
442
443fn check_npx(entry: &ConfigEntry, ext: &Externals) -> Check {
444 let pinned = entry
445 .args
446 .iter()
447 .find_map(|a| a.strip_prefix("mushroomdb@"))
448 .unwrap_or(crate::VERSION);
449 let Some(npx) = ext.which("npx") else {
450 return Check::fail(
451 "npx",
452 "npx is not on PATH",
453 Some(
454 "install Node.js (which provides npx), or re-install with --command <path>"
455 .to_string(),
456 ),
457 );
458 };
459 let args = vec![
460 "-y".to_string(),
461 format!("mushroomdb@{pinned}"),
462 "--version".to_string(),
463 ];
464 match run_capturing(&npx, &args, NPX_TIMEOUT) {
465 RunOutcome::Done(out) if out.contains(pinned) => Check::ok(
466 "npx",
467 format!("npx -y mushroomdb@{pinned} --version -> {}", out.trim()),
468 ),
469 RunOutcome::Done(out) => Check::fail(
470 "npx",
471 format!(
472 "npx -y mushroomdb@{pinned} --version printed {:?}, expected to contain {pinned}",
473 out.trim()
474 ),
475 Some("re-run `mushroomdb install` to repin the version".to_string()),
476 ),
477 RunOutcome::TimedOut => Check::warn(
478 "npx",
479 format!("npx -y mushroomdb@{pinned} --version timed out after {NPX_TIMEOUT:?}"),
480 Some("check network access to the npm registry".to_string()),
481 ),
482 RunOutcome::Failed(e) => Check::fail("npx", e, None),
483 }
484}
485
486fn check_resolved_path(entry: &ConfigEntry) -> Option<Check> {
497 let (name, path) = if entry.command == "node" {
498 ("launcher", Path::new(entry.args.first()?))
499 } else if Path::new(&entry.command).is_absolute() {
500 ("binary", Path::new(&entry.command))
501 } else {
502 return None;
503 };
504 Some(if path.is_file() {
505 Check::ok(name, path.display().to_string())
506 } else {
507 Check::fail(
508 name,
509 format!("{} no longer exists", path.display()),
510 Some("mushroomdb install (re-resolves it)".to_string()),
511 )
512 })
513}
514
515enum RunOutcome {
516 Done(String),
517 TimedOut,
518 Failed(String),
519}
520
521fn run_capturing(bin: &Path, args: &[String], timeout: Duration) -> RunOutcome {
523 let mut child = match Command::new(bin)
524 .args(args)
525 .stdin(Stdio::null())
526 .stdout(Stdio::piped())
527 .stderr(Stdio::null())
528 .spawn()
529 {
530 Ok(c) => c,
531 Err(e) => return RunOutcome::Failed(format!("cannot run {}: {e}", bin.display())),
532 };
533 let mut stdout = child.stdout.take().expect("piped stdout");
534 let (tx, rx) = std::sync::mpsc::channel::<String>();
535 std::thread::spawn(move || {
536 let mut out = String::new();
537 let _ = stdout.read_to_string(&mut out);
538 let _ = tx.send(out);
539 });
540 let deadline = Instant::now() + timeout;
541 loop {
542 match child.try_wait() {
543 Ok(Some(status)) => {
544 let out = rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default();
545 return if status.success() {
546 RunOutcome::Done(out)
547 } else {
548 RunOutcome::Failed(format!("{} exited with {status}", bin.display()))
549 };
550 }
551 Ok(None) if Instant::now() >= deadline => {
552 let _ = child.kill();
553 let _ = child.wait();
554 return RunOutcome::TimedOut;
555 }
556 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
557 Err(e) => return RunOutcome::Failed(format!("cannot wait for {}: {e}", bin.display())),
558 }
559 }
560}
561
562fn check_store_and_lock(db_dir: &Path) -> Vec<Check> {
567 let mut out = Vec::new();
568 let store = GraphDb::open_with_options(
569 db_dir,
570 OpenOptions {
571 read_only: true,
572 auto_migrate: true,
573 repair_wal: true,
574 },
575 );
576 match store {
577 Ok(db) => {
578 let stats = db.stats();
579 let stale = db.is_stale().unwrap_or(false);
580 out.push(Check::ok(
581 "store",
582 format!(
583 "{} — {} nodes live ({} tombstoned), {} edges{}",
584 db_dir.display(),
585 stats.nodes_live,
586 stats.nodes_tombstoned,
587 stats.edges,
588 if stale {
589 ", stale (newer commits pending refresh)"
590 } else {
591 ""
592 }
593 ),
594 ));
595 drop(db);
596
597 match GraphDb::open_with_options(db_dir, OpenOptions::default()) {
601 Ok(handle) => {
602 drop(handle);
603 out.push(Check::ok(
604 "lock",
605 "free — no other process is writing".to_string(),
606 ));
607 }
608 Err(GraphError::Busy { .. }) => out.push(Check::warn(
609 "lock",
610 "another process is writing".to_string(),
611 Some("re-run once the other process finishes".to_string()),
612 )),
613 Err(e) => out.push(Check::warn("lock", format!("could not verify: {e}"), None)),
614 }
615 }
616 Err(e) => out.push(Check::fail(
617 "store",
618 format!("cannot open {}: {e}", db_dir.display()),
619 Some(format!("mushroomdb verify {}", db_dir.display())),
620 )),
621 }
622 out
623}
624
625fn check_hooks(project_root: &Path, home: &Path, scope: Scope, store: &StoreRef) -> Check {
630 let settings_file = match scope {
631 Scope::Project => project_root.join(".claude").join("settings.json"),
632 Scope::User => home.join(".claude").join("settings.json"),
633 };
634 let root = read_json(&settings_file).unwrap_or(Js::Null);
635 let has_recall = has_hook_matching(&root, HOOK_EVENT, "recall", store);
636 let has_touch = has_hook_matching(&root, TOUCH_EVENT, "touch", store);
637 let has_brief = has_hook_matching(&root, BRIEF_EVENT, "brief", store);
638 if has_recall && has_touch && has_brief {
639 Check::ok(
640 "hooks",
641 format!(
642 "{HOOK_EVENT} + {TOUCH_EVENT} + {BRIEF_EVENT} present in {}",
643 settings_file.display()
644 ),
645 )
646 } else {
647 let mut missing = Vec::new();
648 if !has_recall {
649 missing.push(HOOK_EVENT);
650 }
651 if !has_touch {
652 missing.push(TOUCH_EVENT);
653 }
654 if !has_brief {
655 missing.push(BRIEF_EVENT);
656 }
657 Check::warn(
658 "hooks",
659 format!(
660 "missing {} in {}",
661 missing.join(", "),
662 settings_file.display()
663 ),
664 Some("mushroomdb install --platform claude-code".to_string()),
665 )
666 }
667}
668
669fn check_intercept(project_root: &Path, home: &Path, scope: Scope, store: &StoreRef) -> Check {
671 let settings_file = match scope {
672 Scope::Project => project_root.join(".claude").join("settings.json"),
673 Scope::User => home.join(".claude").join("settings.json"),
674 };
675 let root = read_json(&settings_file).unwrap_or(Js::Null);
676 if has_hook_matching(&root, INTERCEPT_EVENT, "intercept", store) {
677 Check::ok(
678 "intercept",
679 format!(
680 "{INTERCEPT_EVENT} (Grep) present in {}",
681 settings_file.display()
682 ),
683 )
684 } else {
685 Check::warn(
686 "intercept",
687 format!("missing {INTERCEPT_EVENT} in {}", settings_file.display()),
688 Some("mushroomdb install --platform claude-code --intercept-grep".to_string()),
689 )
690 }
691}
692
693fn has_hook_matching(root: &Js, event: &str, sub: &str, store: &StoreRef) -> bool {
694 root["hooks"][event]
695 .as_array()
696 .map(|groups| {
697 groups.iter().any(|g| {
698 g["hooks"]
699 .as_array()
700 .map(|hs| {
701 hs.iter().any(|h| {
702 h["command"]
703 .as_str()
704 .is_some_and(|c| is_our_hook_command(c, sub, store))
705 })
706 })
707 .unwrap_or(false)
708 })
709 })
710 .unwrap_or(false)
711}
712
713fn check_git_hooks(project_root: &Path, store: &StoreRef) -> Option<Check> {
718 let dir = git_hooks_dir(project_root)?;
719 let missing: Vec<&str> = GIT_HOOKS
723 .iter()
724 .filter(|name| {
725 let content = std::fs::read_to_string(dir.join(name)).unwrap_or_default();
726 let names_store = content
727 .lines()
728 .any(|l| line_runs_for_store(l, "sync", store));
729 !(content.contains(HOOK_BEGIN) && names_store)
730 })
731 .copied()
732 .collect();
733 Some(if missing.is_empty() {
734 Check::ok(
735 "git-hooks",
736 format!("{} present in {}", GIT_HOOKS.join("/"), dir.display()),
737 )
738 } else {
739 Check::warn(
740 "git-hooks",
741 format!("missing in {}: {}", dir.display(), missing.join(", ")),
742 Some("mushroomdb install --project (omit --no-git-hooks)".to_string()),
743 )
744 })
745}
746
747fn check_scope_conflict(project_root: &Path, home: &Path, scope: Scope) -> Check {
752 let (other_file, other_label, other_flag) = match scope {
753 Scope::Project => (
754 claude_mcp_file(project_root, home, Scope::User),
755 "user",
756 "--user",
757 ),
758 Scope::User => (
759 claude_mcp_file(project_root, home, Scope::Project),
760 "project",
761 "--project",
762 ),
763 };
764 if has_our_server(&other_file) {
765 Check::warn(
766 "scope",
767 format!(
768 "a {other_label}-scope mushroomdb server also exists ({}) — both will load",
769 other_file.display()
770 ),
771 Some(format!("mushroomdb uninstall {other_flag}")),
772 )
773 } else {
774 Check::ok(
775 "scope",
776 "no duplicate server in the other scope".to_string(),
777 )
778 }
779}
780
781const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
786
787struct HandshakeOk {
788 version: String,
789 tool_count: usize,
790 task_tool: String,
792}
793
794fn check_handshake(entry: &ConfigEntry) -> Check {
795 match self_handshake(&entry.command, &entry.args) {
796 Ok(HandshakeOk {
797 version,
798 tool_count,
799 task_tool,
800 }) => Check::ok(
801 "handshake",
802 format!(
803 "initialize + tools/list ok — version {version}, {tool_count} tools \
804 ({task_tool} present)"
805 ),
806 ),
807 Err(msg) => Check::fail(
808 "handshake",
809 msg,
810 Some(format!(
811 "verify `{} {}` runs mushroomdb's MCP server, or re-run `mushroomdb install` \
812 to rewrite the command",
813 entry.command,
814 entry.args.join(" ")
815 )),
816 ),
817 }
818}
819
820fn self_handshake(command: &str, args: &[String]) -> Result<HandshakeOk, String> {
827 let mut child = Command::new(command)
828 .args(args)
829 .stdin(Stdio::piped())
830 .stdout(Stdio::piped())
831 .stderr(Stdio::null())
832 .spawn()
833 .map_err(|e| format!("cannot spawn `{command}`: {e}"))?;
834
835 let mut stdin = child.stdin.take().expect("piped stdin");
836 let stdout = child.stdout.take().expect("piped stdout");
837
838 let (tx, rx) = std::sync::mpsc::channel::<String>();
839 std::thread::spawn(move || {
840 let mut reader = BufReader::new(stdout);
841 let mut line = String::new();
842 loop {
843 line.clear();
844 match reader.read_line(&mut line) {
845 Ok(0) | Err(_) => break,
846 Ok(_) => {
847 if tx.send(line.trim().to_string()).is_err() {
848 break;
849 }
850 }
851 }
852 }
853 });
854
855 let sent = writeln!(
856 stdin,
857 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mushroomdb-doctor","version":"1"}}}}}}"#
858 )
859 .and_then(|()| writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#))
860 .and_then(|()| stdin.flush());
861
862 let mut init_resp: Option<Js> = None;
863 let mut list_resp: Option<Js> = None;
864 if sent.is_ok() {
865 let deadline = Instant::now() + HANDSHAKE_TIMEOUT;
866 while (init_resp.is_none() || list_resp.is_none()) && Instant::now() < deadline {
867 let remaining = deadline.saturating_duration_since(Instant::now());
868 match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
869 Ok(line) if !line.is_empty() => {
870 if let Ok(v) = serde_json::from_str::<Js>(&line) {
871 match v.get("id").and_then(Js::as_i64) {
872 Some(1) => init_resp = Some(v),
873 Some(2) => list_resp = Some(v),
874 _ => {}
875 }
876 }
877 }
878 Ok(_) => {}
879 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
880 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
881 }
882 }
883 }
884
885 drop(stdin);
888 let reap_deadline = Instant::now() + Duration::from_secs(2);
889 loop {
890 match child.try_wait() {
891 Ok(Some(_)) | Err(_) => break,
892 Ok(None) if Instant::now() >= reap_deadline => {
893 let _ = child.kill();
894 let _ = child.wait();
895 break;
896 }
897 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
898 }
899 }
900
901 if sent.is_err() {
902 return Err(format!("cannot write to `{command}`'s stdin"));
903 }
904 let init = init_resp.ok_or_else(|| {
905 format!("`{command}` did not answer `initialize` within {HANDSHAKE_TIMEOUT:?}")
906 })?;
907 let list = list_resp.ok_or_else(|| {
908 format!("`{command}` did not answer `tools/list` within {HANDSHAKE_TIMEOUT:?}")
909 })?;
910
911 let version = init["result"]["serverInfo"]["version"]
912 .as_str()
913 .ok_or_else(|| format!("`{command}`: initialize response has no serverInfo.version"))?
914 .to_string();
915 if version != crate::VERSION {
916 return Err(format!(
917 "`{command}` reports version {version}, expected {}",
918 crate::VERSION
919 ));
920 }
921 let tools = list["result"]["tools"]
922 .as_array()
923 .ok_or_else(|| format!("`{command}`: tools/list response has no tools array"))?;
924 let task_tool = tools
925 .iter()
926 .filter_map(|t| t["name"].as_str())
927 .find(|name| TASK_PATH_TOOLS.contains(name))
928 .ok_or_else(|| {
929 format!(
930 "`{command}`: tools/list includes none of {}",
931 TASK_PATH_TOOLS.join(", ")
932 )
933 })?
934 .to_string();
935
936 Ok(HandshakeOk {
937 version,
938 tool_count: tools.len(),
939 task_tool,
940 })
941}
942
943const TASK_PATH_TOOLS: [&str; 2] = ["explore", "map"];