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, is_disabled, is_our_hook_command, line_runs_for_store,
21 opt_ins, resolve_platform, resolve_scope, Externals, Platform, Scope, StoreRef, AUTO_ARG,
22 BRIEF_EVENT, ENRICH_EVENT, GIT_HOOKS, HOOK_BEGIN, HOOK_EVENT, IMPACT_EVENT, INTERCEPT_EVENT,
23 SERVER_NAME, TOUCH_EVENT,
24};
25use crate::CliError;
26use core_api::{GraphDb, GraphError, OpenOptions};
27use serde_json::Value as Js;
28use std::io::{BufRead, BufReader, Read as _, Write as _};
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{Duration, Instant};
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DoctorOpts {
36 pub platform: Option<Platform>,
38 pub scope: Option<Scope>,
40}
41
42pub struct DoctorReport {
44 pub output: String,
46 pub had_fail: bool,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum Status {
52 Ok,
53 Skip,
55 Warn,
56 Fail,
57}
58
59impl Status {
60 fn word(self) -> &'static str {
61 match self {
62 Status::Ok => "ok",
63 Status::Skip => "skip",
64 Status::Warn => "warn",
65 Status::Fail => "fail",
66 }
67 }
68}
69
70struct Check {
73 status: Status,
74 name: &'static str,
75 message: String,
76 fix: Option<String>,
77}
78
79impl Check {
80 fn ok(name: &'static str, message: impl Into<String>) -> Self {
81 Check {
82 status: Status::Ok,
83 name,
84 message: message.into(),
85 fix: None,
86 }
87 }
88 fn skip(name: &'static str, message: impl Into<String>) -> Self {
89 Check {
90 status: Status::Skip,
91 name,
92 message: message.into(),
93 fix: None,
94 }
95 }
96 fn warn(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
97 Check {
98 status: Status::Warn,
99 name,
100 message: message.into(),
101 fix,
102 }
103 }
104 fn fail(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
105 Check {
106 status: Status::Fail,
107 name,
108 message: message.into(),
109 fix,
110 }
111 }
112 fn render(&self) -> String {
113 let mut line = format!(
116 "{:<4} {:<11} {}",
117 self.status.word(),
118 self.name,
119 self.message
120 );
121 if let Some(fix) = &self.fix {
122 line.push_str(&format!(" fix: {fix}"));
123 }
124 line.push('\n');
125 line
126 }
127}
128
129pub fn run_doctor(
131 project_root: &Path,
132 home: &Path,
133 opts: &DoctorOpts,
134) -> Result<DoctorReport, CliError> {
135 run_doctor_with(project_root, home, opts, &Externals::from_env())
136}
137
138pub fn run_doctor_with(
142 project_root: &Path,
143 home: &Path,
144 opts: &DoctorOpts,
145 ext: &Externals,
146) -> Result<DoctorReport, CliError> {
147 let (scope, _auto_scope) = resolve_scope(project_root, opts.scope);
148 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
149 let platforms = expand_platform(&resolved);
150
151 if is_disabled(project_root, home, scope, &platforms) {
155 return Ok(DoctorReport {
156 output: Check::warn("state", "disabled — enable with: mushroomdb enable", None)
157 .render(),
158 had_fail: false,
159 });
160 }
161
162 let (delivery, recorded_store) = installed_shape(project_root, home, scope, &platforms);
168 let mcp_delivery = delivery.wires_mcp();
169
170 let mut checks: Vec<Check> = Vec::new();
171 let mut primary: Option<(Platform, ConfigEntry)> = None;
172
173 for plat in &platforms {
176 if !mcp_delivery && matches!(plat, Platform::ClaudeCode) {
180 checks.push(Check::skip(
181 "config",
182 format!(
183 "delivery: {} — the skill teaches the binary, no MCP entry to check",
184 delivery.label()
185 ),
186 ));
187 continue;
188 }
189 match mcp_file_for(plat, project_root, home, scope) {
190 None => checks.push(Check::warn(
191 "config",
192 format!(
193 "{}'s configuration is owned by its own CLI — not checked here",
194 plat.label()
195 ),
196 None,
197 )),
198 Some(mcp_file) => match read_config_entry(&mcp_file, project_root, home) {
199 Ok(entry) => {
200 checks.push(Check::ok(
201 "config",
202 format!(
203 "{} — {} -> {}",
204 plat.label(),
205 mcp_file.display(),
206 entry.describe_store()
207 ),
208 ));
209 if primary.is_none() {
210 primary = Some((plat.clone(), entry));
211 }
212 }
213 Err(msg) => checks.push(Check::fail("config", msg, Some(install_fix(scope, plat)))),
214 },
215 }
216 }
217
218 let store: Option<StoreRef> = match &primary {
221 Some((_, entry)) => Some(entry.store.clone()),
222 None if !mcp_delivery => recorded_store,
223 None => None,
224 };
225
226 if let Some((_, entry)) = &primary {
229 if entry.command == "npx" {
230 checks.push(check_npx(entry, ext));
231 } else if let Some(check) = check_resolved_path(entry) {
232 checks.push(check);
233 }
234 }
235
236 let mut store_opened_in: Option<Duration> = None;
241 match &store {
242 Some(store) => {
243 let StoreChecks {
244 checks: cs,
245 opened_in,
246 } = check_store_and_lock(store.path());
247 checks.extend(cs);
248 store_opened_in = opened_in;
249 }
250 None => checks.push(Check::fail(
251 "store",
252 no_store_message(mcp_delivery),
253 Some(install_fix_for_scope(scope)),
254 )),
255 }
256
257 if platforms.contains(&Platform::ClaudeCode) {
259 if let Some(store) = &store {
260 checks.push(check_hooks(project_root, home, scope, store));
261 let opted = opt_ins(project_root, home, scope, &platforms);
265 let settings_file = settings_file(project_root, home, scope);
266 let settings = read_json(&settings_file).unwrap_or(Js::Null);
267 for (asked, sub, event, matcher, flag) in [
268 (
269 opted.intercept_grep,
270 "intercept",
271 INTERCEPT_EVENT,
272 "Grep",
273 "--intercept-grep",
274 ),
275 (
276 opted.impact_before_edit,
277 "impact-hook",
278 IMPACT_EVENT,
279 "Edit|Write|MultiEdit",
280 "--impact-before-edit",
281 ),
282 (
283 opted.enrich_grep,
284 "enrich",
285 ENRICH_EVENT,
286 "Grep",
287 "--enrich-grep",
288 ),
289 ] {
290 if asked {
291 checks.push(check_opt_in_hook(
292 sub,
293 event,
294 matcher,
295 flag,
296 &settings_file,
297 &settings,
298 store,
299 ));
300 }
301 }
302 if opted.always_load {
303 checks.push(check_always_load(project_root, home, scope));
304 }
305 }
306 }
307
308 if scope == Scope::Project
311 && platforms
312 .iter()
313 .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor))
314 {
315 if let Some(store) = &store {
316 if let Some(check) = check_git_hooks(project_root, store) {
317 checks.push(check);
318 }
319 }
320 }
321
322 match &primary {
324 Some((_, entry)) => checks.push(check_handshake(entry, store_opened_in)),
325 None if !mcp_delivery => checks.push(Check::skip(
327 "handshake",
328 format!("delivery: {} — no server to spawn", delivery.label()),
329 )),
330 None => checks.push(Check::fail(
331 "handshake",
332 "no usable config entry — nothing to spawn",
333 Some(install_fix_for_scope(scope)),
334 )),
335 }
336
337 if platforms.contains(&Platform::ClaudeCode) {
339 checks.push(check_scope_conflict(project_root, home, scope));
340 }
341
342 let had_fail = checks.iter().any(|c| c.status == Status::Fail);
343 let mut output = String::new();
344 for c in &checks {
345 output.push_str(&c.render());
346 }
347 Ok(DoctorReport { output, had_fail })
348}
349
350fn no_store_message(mcp_delivery: bool) -> &'static str {
353 if mcp_delivery {
354 "no usable config entry — cannot locate a database to check"
355 } else {
356 "no recorded SessionStart hook — cannot locate a database to check"
357 }
358}
359
360fn install_fix_for_scope(scope: Scope) -> String {
361 format!(
362 "mushroomdb install {}",
363 match scope {
364 Scope::Project => "--project",
365 Scope::User => "--user",
366 }
367 )
368}
369
370fn install_fix(scope: Scope, plat: &Platform) -> String {
371 format!(
372 "mushroomdb install --platform {} {}",
373 plat.label(),
374 match scope {
375 Scope::Project => "--project",
376 Scope::User => "--user",
377 }
378 )
379}
380
381fn mcp_file_for(
382 plat: &Platform,
383 project_root: &Path,
384 home: &Path,
385 scope: Scope,
386) -> Option<PathBuf> {
387 match plat {
388 Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
389 Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
390 Platform::Codex | Platform::All => None,
391 }
392}
393
394struct ConfigEntry {
400 store: StoreRef,
402 command: String,
403 args: Vec<String>,
404}
405
406impl ConfigEntry {
407 fn describe_store(&self) -> String {
411 if self.store.is_auto() {
412 format!("{AUTO_ARG} -> {}", self.store.path().display())
413 } else {
414 self.store.path().display().to_string()
415 }
416 }
417}
418
419fn entry_store(arg: &str, project_root: &Path, home: &Path) -> StoreRef {
428 if arg == AUTO_ARG {
429 return StoreRef::auto(crate::resolve_auto_db(None, project_root, home));
430 }
431 let path = PathBuf::from(arg);
432 if path == default_db(Scope::Project, project_root, home) {
433 return StoreRef::pinned(path).also_auto();
434 }
435 StoreRef::pinned(path)
436}
437
438fn read_json(path: &Path) -> Result<Js, String> {
439 let raw = std::fs::read_to_string(path)
440 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
441 serde_json::from_str(&raw).map_err(|e| format!("invalid JSON in {}: {e}", path.display()))
442}
443
444fn read_config_entry(
445 mcp_file: &Path,
446 project_root: &Path,
447 home: &Path,
448) -> Result<ConfigEntry, String> {
449 if !mcp_file.exists() {
450 return Err(format!("{} does not exist", mcp_file.display()));
451 }
452 let root = read_json(mcp_file)?;
453 let entry = &root["mcpServers"]["mushroomdb"];
454 if entry.is_null() {
455 return Err(format!(
456 "no mcpServers.mushroomdb entry in {}",
457 mcp_file.display()
458 ));
459 }
460 let store = entry_store(
461 entry_db(entry).ok_or_else(|| {
462 format!(
463 "{}: mushroomdb entry has no `mcp <db>|--auto` argument",
464 mcp_file.display()
465 )
466 })?,
467 project_root,
468 home,
469 );
470 let command = entry["command"]
471 .as_str()
472 .ok_or_else(|| format!("{}: mushroomdb entry has no `command`", mcp_file.display()))?
473 .to_string();
474 let args = entry["args"]
475 .as_array()
476 .map(|a| {
477 a.iter()
478 .filter_map(|v| v.as_str().map(str::to_string))
479 .collect()
480 })
481 .unwrap_or_default();
482 Ok(ConfigEntry {
483 store,
484 command,
485 args,
486 })
487}
488
489const NPX_TIMEOUT: Duration = Duration::from_secs(60);
494
495fn check_npx(entry: &ConfigEntry, ext: &Externals) -> Check {
496 let pinned = entry
497 .args
498 .iter()
499 .find_map(|a| a.strip_prefix("mushroomdb@"))
500 .unwrap_or(crate::VERSION);
501 let Some(npx) = ext.which("npx") else {
502 return Check::fail(
503 "npx",
504 "npx is not on PATH",
505 Some(
506 "install Node.js (which provides npx), or re-install with --command <path>"
507 .to_string(),
508 ),
509 );
510 };
511 let args = vec![
512 "-y".to_string(),
513 format!("mushroomdb@{pinned}"),
514 "--version".to_string(),
515 ];
516 match run_capturing(&npx, &args, NPX_TIMEOUT) {
517 RunOutcome::Done(out) if out.contains(pinned) => Check::ok(
518 "npx",
519 format!("npx -y mushroomdb@{pinned} --version -> {}", out.trim()),
520 ),
521 RunOutcome::Done(out) => Check::fail(
522 "npx",
523 format!(
524 "npx -y mushroomdb@{pinned} --version printed {:?}, expected to contain {pinned}",
525 out.trim()
526 ),
527 Some("re-run `mushroomdb install` to repin the version".to_string()),
528 ),
529 RunOutcome::TimedOut => Check::warn(
530 "npx",
531 format!("npx -y mushroomdb@{pinned} --version timed out after {NPX_TIMEOUT:?}"),
532 Some("check network access to the npm registry".to_string()),
533 ),
534 RunOutcome::Failed(e) => Check::fail("npx", e, None),
535 }
536}
537
538fn check_resolved_path(entry: &ConfigEntry) -> Option<Check> {
549 let (name, path) = if entry.command == "node" {
550 ("launcher", Path::new(entry.args.first()?))
551 } else if Path::new(&entry.command).is_absolute() {
552 ("binary", Path::new(&entry.command))
553 } else {
554 return None;
555 };
556 Some(if path.is_file() {
557 Check::ok(name, path.display().to_string())
558 } else {
559 Check::fail(
560 name,
561 format!("{} no longer exists", path.display()),
562 Some("mushroomdb install (re-resolves it)".to_string()),
563 )
564 })
565}
566
567enum RunOutcome {
568 Done(String),
569 TimedOut,
570 Failed(String),
571}
572
573fn run_capturing(bin: &Path, args: &[String], timeout: Duration) -> RunOutcome {
575 let mut child = match Command::new(bin)
576 .args(args)
577 .stdin(Stdio::null())
578 .stdout(Stdio::piped())
579 .stderr(Stdio::null())
580 .spawn()
581 {
582 Ok(c) => c,
583 Err(e) => return RunOutcome::Failed(format!("cannot run {}: {e}", bin.display())),
584 };
585 let mut stdout = child.stdout.take().expect("piped stdout");
586 let (tx, rx) = std::sync::mpsc::channel::<String>();
587 std::thread::spawn(move || {
588 let mut out = String::new();
589 let _ = stdout.read_to_string(&mut out);
590 let _ = tx.send(out);
591 });
592 let deadline = Instant::now() + timeout;
593 loop {
594 match child.try_wait() {
595 Ok(Some(status)) => {
596 let out = rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default();
597 return if status.success() {
598 RunOutcome::Done(out)
599 } else {
600 RunOutcome::Failed(format!("{} exited with {status}", bin.display()))
601 };
602 }
603 Ok(None) if Instant::now() >= deadline => {
604 let _ = child.kill();
605 let _ = child.wait();
606 return RunOutcome::TimedOut;
607 }
608 Ok(None) => std::thread::sleep(Duration::from_millis(25)),
609 Err(e) => return RunOutcome::Failed(format!("cannot wait for {}: {e}", bin.display())),
610 }
611 }
612}
613
614struct StoreChecks {
624 checks: Vec<Check>,
625 opened_in: Option<Duration>,
626}
627
628fn check_store_and_lock(db_dir: &Path) -> StoreChecks {
629 let mut out = Vec::new();
630 let started = Instant::now();
631 let store = GraphDb::open_with_options(
632 db_dir,
633 OpenOptions {
634 read_only: true,
635 auto_migrate: true,
636 repair_wal: true,
637 },
638 );
639 let mut opened_in = started.elapsed();
642 match store {
643 Ok(db) => {
644 let stats = db.stats();
645 let stale = db.is_stale().unwrap_or(false);
646 let floor = db.wal_horizon_floor();
647 let total = db.wal_total_commits().unwrap_or(floor);
648 let namespaces = if stats.namespaces.len() > 1 {
653 format!(", {} namespaces", stats.namespaces.len())
654 } else {
655 String::new()
656 };
657 out.push(Check::ok(
658 "store",
659 format!(
660 "{} — {} nodes live ({} tombstoned), {} edges{}, history from commit {} of {}{}",
661 db_dir.display(),
662 stats.nodes_live,
663 stats.nodes_tombstoned,
664 stats.edges,
665 namespaces,
666 floor,
667 total,
668 if stale {
669 ", stale (newer commits pending refresh)"
670 } else {
671 ""
672 }
673 ),
674 ));
675 drop(db);
676
677 let write_started = Instant::now();
686 match GraphDb::open_with_options(db_dir, OpenOptions::default()) {
687 Ok(handle) => {
688 opened_in = opened_in.max(write_started.elapsed());
689 drop(handle);
690 out.push(Check::ok(
691 "lock",
692 "free — no other process is writing".to_string(),
693 ));
694 }
695 Err(GraphError::Busy { .. }) => out.push(Check::warn(
696 "lock",
697 "another process is writing".to_string(),
698 Some("re-run once the other process finishes".to_string()),
699 )),
700 Err(e) => out.push(Check::warn("lock", format!("could not verify: {e}"), None)),
701 }
702 }
703 Err(e) => out.push(Check::fail(
704 "store",
705 format!("cannot open {}: {e}", db_dir.display()),
706 Some(format!("mushroomdb verify {}", db_dir.display())),
707 )),
708 }
709 StoreChecks {
710 checks: out,
711 opened_in: Some(opened_in),
712 }
713}
714
715fn check_hooks(project_root: &Path, home: &Path, scope: Scope, store: &StoreRef) -> Check {
720 let settings_file = match scope {
721 Scope::Project => project_root.join(".claude").join("settings.json"),
722 Scope::User => home.join(".claude").join("settings.json"),
723 };
724 let root = read_json(&settings_file).unwrap_or(Js::Null);
725 let has_recall = has_hook_matching(&root, HOOK_EVENT, "recall", store);
726 let has_touch = has_hook_matching(&root, TOUCH_EVENT, "touch", store);
727 let has_brief = has_hook_matching(&root, BRIEF_EVENT, "brief", store);
728 if has_recall && has_touch && has_brief {
729 Check::ok(
730 "hooks",
731 format!(
732 "{HOOK_EVENT} + {TOUCH_EVENT} + {BRIEF_EVENT} present in {}",
733 settings_file.display()
734 ),
735 )
736 } else {
737 let mut missing = Vec::new();
738 if !has_recall {
739 missing.push(HOOK_EVENT);
740 }
741 if !has_touch {
742 missing.push(TOUCH_EVENT);
743 }
744 if !has_brief {
745 missing.push(BRIEF_EVENT);
746 }
747 Check::warn(
748 "hooks",
749 format!(
750 "missing {} in {}",
751 missing.join(", "),
752 settings_file.display()
753 ),
754 Some("mushroomdb install --platform claude-code".to_string()),
755 )
756 }
757}
758
759fn check_always_load(project_root: &Path, home: &Path, scope: Scope) -> Check {
770 let mcp_file = claude_mcp_file(project_root, home, scope);
771 let entry = read_json(&mcp_file).unwrap_or(Js::Null);
772 if entry["mcpServers"][SERVER_NAME]["alwaysLoad"] == Js::Bool(true) {
773 Check::ok(
774 "always-load",
775 format!(
776 "mcpServers.{SERVER_NAME} is marked alwaysLoad in {}",
777 mcp_file.display()
778 ),
779 )
780 } else {
781 Check::warn(
782 "always-load",
783 format!("manifest records it but {} does not", mcp_file.display()),
784 Some("mushroomdb install --platform claude-code --always-load".to_string()),
785 )
786 }
787}
788
789fn settings_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
791 match scope {
792 Scope::Project => project_root.join(".claude").join("settings.json"),
793 Scope::User => home.join(".claude").join("settings.json"),
794 }
795}
796
797fn check_opt_in_hook(
803 sub: &'static str,
804 event: &str,
805 matcher: &str,
806 flag: &str,
807 settings_file: &Path,
808 root: &Js,
809 store: &StoreRef,
810) -> Check {
811 if has_hook_matching(root, event, sub, store) {
812 Check::ok(
813 sub,
814 format!("{event} ({matcher}) present in {}", settings_file.display()),
815 )
816 } else {
817 Check::warn(
818 sub,
819 format!("missing {event} in {}", settings_file.display()),
820 Some(format!("mushroomdb install --platform claude-code {flag}")),
821 )
822 }
823}
824
825fn has_hook_matching(root: &Js, event: &str, sub: &str, store: &StoreRef) -> bool {
826 root["hooks"][event]
827 .as_array()
828 .map(|groups| {
829 groups.iter().any(|g| {
830 g["hooks"]
831 .as_array()
832 .map(|hs| {
833 hs.iter().any(|h| {
834 h["command"]
835 .as_str()
836 .is_some_and(|c| is_our_hook_command(c, sub, store))
837 })
838 })
839 .unwrap_or(false)
840 })
841 })
842 .unwrap_or(false)
843}
844
845fn check_git_hooks(project_root: &Path, store: &StoreRef) -> Option<Check> {
850 let dir = git_hooks_dir(project_root)?;
851 let missing: Vec<&str> = GIT_HOOKS
855 .iter()
856 .filter(|name| {
857 let content = std::fs::read_to_string(dir.join(name)).unwrap_or_default();
858 let names_store = content
859 .lines()
860 .any(|l| line_runs_for_store(l, "sync", store));
861 !(content.contains(HOOK_BEGIN) && names_store)
862 })
863 .copied()
864 .collect();
865 Some(if missing.is_empty() {
866 Check::ok(
867 "git-hooks",
868 format!("{} present in {}", GIT_HOOKS.join("/"), dir.display()),
869 )
870 } else {
871 Check::warn(
872 "git-hooks",
873 format!("missing in {}: {}", dir.display(), missing.join(", ")),
874 Some("mushroomdb install --project (omit --no-git-hooks)".to_string()),
875 )
876 })
877}
878
879fn check_scope_conflict(project_root: &Path, home: &Path, scope: Scope) -> Check {
884 let (other_file, other_label, other_flag) = match scope {
885 Scope::Project => (
886 claude_mcp_file(project_root, home, Scope::User),
887 "user",
888 "--user",
889 ),
890 Scope::User => (
891 claude_mcp_file(project_root, home, Scope::Project),
892 "project",
893 "--project",
894 ),
895 };
896 if has_our_server(&other_file) {
897 Check::warn(
898 "scope",
899 format!(
900 "a {other_label}-scope mushroomdb server also exists ({}) — both will load",
901 other_file.display()
902 ),
903 Some(format!("mushroomdb uninstall {other_flag}")),
904 )
905 } else {
906 Check::ok(
907 "scope",
908 "no duplicate server in the other scope".to_string(),
909 )
910 }
911}
912
913const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
921
922const HANDSHAKE_SPAWN_SLACK: Duration = Duration::from_secs(5);
925
926fn slow_store_hint(store_opened_in: Option<Duration>, deadline: Duration) -> String {
936 match store_opened_in {
937 Some(open) if deadline > HANDSHAKE_TIMEOUT => format!(
938 "; the store took {:.1}s to open here, so the server was already given \
939 {:.1}s and still did not answer — if the command is right, run \
940 `mushroomdb build-index` and `mushroomdb snapshot` to make the open cheap",
941 open.as_secs_f64(),
942 deadline.as_secs_f64()
943 ),
944 _ => String::new(),
945 }
946}
947
948fn handshake_deadline(store_opened_in: Option<Duration>) -> Duration {
963 match store_opened_in {
964 Some(open) => HANDSHAKE_TIMEOUT.max(open * 2 + HANDSHAKE_SPAWN_SLACK),
965 None => HANDSHAKE_TIMEOUT,
966 }
967}
968
969struct HandshakeOk {
970 version: String,
971 tool_count: usize,
972 task_tool: String,
974}
975
976fn check_handshake(entry: &ConfigEntry, store_opened_in: Option<Duration>) -> Check {
977 let deadline = handshake_deadline(store_opened_in);
978 match self_handshake(&entry.command, &entry.args, deadline) {
979 Ok(HandshakeOk {
980 version,
981 tool_count,
982 task_tool,
983 }) => Check::ok(
984 "handshake",
985 format!(
986 "initialize + tools/list ok — version {version}, {tool_count} tools \
987 ({task_tool} present)"
988 ),
989 ),
990 Err(msg) => Check::fail(
991 "handshake",
992 msg,
993 Some(format!(
994 "verify `{} {}` runs mushroomdb's MCP server, or re-run `mushroomdb install` \
995 to rewrite the command{}",
996 entry.command,
997 entry.args.join(" "),
998 slow_store_hint(store_opened_in, deadline)
999 )),
1000 ),
1001 }
1002}
1003
1004fn self_handshake(
1012 command: &str,
1013 args: &[String],
1014 timeout: Duration,
1015) -> Result<HandshakeOk, String> {
1016 let mut child = Command::new(command)
1017 .args(args)
1018 .stdin(Stdio::piped())
1019 .stdout(Stdio::piped())
1020 .stderr(Stdio::null())
1021 .spawn()
1022 .map_err(|e| format!("cannot spawn `{command}`: {e}"))?;
1023
1024 let mut stdin = child.stdin.take().expect("piped stdin");
1025 let stdout = child.stdout.take().expect("piped stdout");
1026
1027 let (tx, rx) = std::sync::mpsc::channel::<String>();
1028 std::thread::spawn(move || {
1029 let mut reader = BufReader::new(stdout);
1030 let mut line = String::new();
1031 loop {
1032 line.clear();
1033 match reader.read_line(&mut line) {
1034 Ok(0) | Err(_) => break,
1035 Ok(_) => {
1036 if tx.send(line.trim().to_string()).is_err() {
1037 break;
1038 }
1039 }
1040 }
1041 }
1042 });
1043
1044 let sent = writeln!(
1045 stdin,
1046 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mushroomdb-doctor","version":"1"}}}}}}"#
1047 )
1048 .and_then(|()| writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#))
1049 .and_then(|()| stdin.flush());
1050
1051 let mut init_resp: Option<Js> = None;
1052 let mut list_resp: Option<Js> = None;
1053 if sent.is_ok() {
1054 let deadline = Instant::now() + timeout;
1055 while (init_resp.is_none() || list_resp.is_none()) && Instant::now() < deadline {
1056 let remaining = deadline.saturating_duration_since(Instant::now());
1057 match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
1058 Ok(line) if !line.is_empty() => {
1059 if let Ok(v) = serde_json::from_str::<Js>(&line) {
1060 match v.get("id").and_then(Js::as_i64) {
1061 Some(1) => init_resp = Some(v),
1062 Some(2) => list_resp = Some(v),
1063 _ => {}
1064 }
1065 }
1066 }
1067 Ok(_) => {}
1068 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
1069 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1070 }
1071 }
1072 }
1073
1074 drop(stdin);
1077 let reap_deadline = Instant::now() + Duration::from_secs(2);
1078 loop {
1079 match child.try_wait() {
1080 Ok(Some(_)) | Err(_) => break,
1081 Ok(None) if Instant::now() >= reap_deadline => {
1082 let _ = child.kill();
1083 let _ = child.wait();
1084 break;
1085 }
1086 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
1087 }
1088 }
1089
1090 if sent.is_err() {
1091 return Err(format!("cannot write to `{command}`'s stdin"));
1092 }
1093 let init = init_resp
1094 .ok_or_else(|| format!("`{command}` did not answer `initialize` within {timeout:?}"))?;
1095 let list = list_resp
1096 .ok_or_else(|| format!("`{command}` did not answer `tools/list` within {timeout:?}"))?;
1097
1098 let version = init["result"]["serverInfo"]["version"]
1099 .as_str()
1100 .ok_or_else(|| format!("`{command}`: initialize response has no serverInfo.version"))?
1101 .to_string();
1102 if version != crate::VERSION {
1103 return Err(format!(
1104 "`{command}` reports version {version}, expected {}",
1105 crate::VERSION
1106 ));
1107 }
1108 let tools = list["result"]["tools"]
1109 .as_array()
1110 .ok_or_else(|| format!("`{command}`: tools/list response has no tools array"))?;
1111 let task_tool = tools
1112 .iter()
1113 .filter_map(|t| t["name"].as_str())
1114 .find(|name| TASK_PATH_TOOLS.contains(name))
1115 .ok_or_else(|| {
1116 format!(
1117 "`{command}`: tools/list includes none of {}",
1118 TASK_PATH_TOOLS.join(", ")
1119 )
1120 })?
1121 .to_string();
1122
1123 Ok(HandshakeOk {
1124 version,
1125 tool_count: tools.len(),
1126 task_tool,
1127 })
1128}
1129
1130const TASK_PATH_TOOLS: [&str; 2] = ["explore", "explain_association"];
1139
1140#[cfg(test)]
1141mod tests {
1142 use super::*;
1143
1144 #[test]
1152 fn the_handshake_deadline_holds_its_floor_and_scales() {
1153 assert_eq!(handshake_deadline(None), HANDSHAKE_TIMEOUT);
1156
1157 for ms in [0, 1, 50, 500, 2_400] {
1159 assert_eq!(
1160 handshake_deadline(Some(Duration::from_millis(ms))),
1161 HANDSHAKE_TIMEOUT,
1162 "a {ms}ms open must not shorten the deadline below its floor"
1163 );
1164 }
1165
1166 assert_eq!(
1168 handshake_deadline(Some(Duration::from_millis(2_500))),
1169 HANDSHAKE_TIMEOUT
1170 );
1171
1172 assert_eq!(
1174 handshake_deadline(Some(Duration::from_secs(3))),
1175 Duration::from_secs(11)
1176 );
1177 let ci = Duration::from_millis(13_300);
1179 let got = handshake_deadline(Some(ci));
1180 assert_eq!(got, Duration::from_millis(31_600));
1181 assert!(
1182 got > ci,
1183 "the deadline must exceed the open it was measured from"
1184 );
1185
1186 let mut prev = handshake_deadline(Some(Duration::ZERO));
1188 for s in 1..60 {
1189 let next = handshake_deadline(Some(Duration::from_secs(s)));
1190 assert!(next >= prev, "deadline shrank at a {s}s open");
1191 prev = next;
1192 }
1193 }
1194
1195 #[test]
1200 fn the_fix_hint_names_a_slow_store_only_when_it_is_the_reason() {
1201 assert_eq!(slow_store_hint(None, HANDSHAKE_TIMEOUT), "");
1203 assert_eq!(
1204 slow_store_hint(Some(Duration::from_millis(80)), HANDSHAKE_TIMEOUT),
1205 ""
1206 );
1207 assert_eq!(slow_store_hint(None, Duration::from_secs(40)), "");
1209
1210 let open = Duration::from_millis(13_300);
1213 let hint = slow_store_hint(Some(open), handshake_deadline(Some(open)));
1214 assert!(hint.contains("13.3s"), "the open time: {hint}");
1215 assert!(hint.contains("31.6s"), "the deadline it bought: {hint}");
1216 assert!(hint.contains("build-index"), "{hint}");
1217 assert!(hint.contains("snapshot"), "{hint}");
1218
1219 let modest = Duration::from_millis(4_800);
1222 assert!(handshake_deadline(Some(modest)) > HANDSHAKE_TIMEOUT);
1223 assert!(
1224 slow_store_hint(Some(modest), handshake_deadline(Some(modest))).contains("4.8s"),
1225 "an open below ten seconds can still be the reason"
1226 );
1227 }
1228}