@vantageos/mcp-doctor 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to `@vantageos/mcp-doctor` are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 0.8.3 — 2026-10-25
7
+
8
+ ### Fixed
9
+ - **A candidate that is present but unreadable is reported as present, never as absent** (task
10
+ k179q736d89ysaete7fhmshjyd8gzh41, Eta on #60). `findDiskFiles` refused a matched entry that was
11
+ chmod 000, a FIFO or a dangling link, recorded the skip, and returned nothing for it. Seventeen
12
+ callers then read that as "not in the tree".
13
+ - `findDiskFiles` now returns the refused matches alongside the readable files, typed as
14
+ `.refused`. `diskText` and `readSkillFiles` carry them too.
15
+ - Every verdict that rests on absence settles a refused match as `could-not-judge` and names the
16
+ file. That covers 15 converted callers; the other 2 are declared at their site.
17
+ - A-2 no longer says "no vite.config.* in the tree" for an unreadable config. It also no longer
18
+ turns an unreadable config into a RED.
19
+ - A-3, A-4 and A-5 no longer report "not an App" when the only HTML shell is unreadable.
20
+ - `src/runtime/launch.ts:164`, the parse of a server's stdout line, is classified as legitimate.
21
+
22
+ On readable input every verdict is unchanged: four real trees give 101 identical results on 0.8.2
23
+ and 0.8.3.
24
+
6
25
  ## 0.8.2 — 2026-10-25
7
26
 
8
27
  ### Fixed
package/dist/doctor.js CHANGED
@@ -519,6 +519,12 @@ function couldNotJudge(id, anchor, reason, owedVerdict) {
519
519
  ...owedVerdict ? { owedVerdict } : {}
520
520
  };
521
521
  }
522
+ function refusedAsUnreadable(refused, what) {
523
+ return refused.map((r) => ({
524
+ anchor: r.rel,
525
+ reason: `${r.rel} ${r.reason}: ${what}`
526
+ }));
527
+ }
522
528
  function settle(id, decided, unreadable) {
523
529
  if (unreadable.length === 0) return decided;
524
530
  const named = unreadable.map((u) => `${u.anchor}: ${u.reason}`).join("; ");
@@ -638,6 +644,7 @@ function baseOf(tree) {
638
644
  }
639
645
  function findDiskFiles(tree, pred) {
640
646
  const out = [];
647
+ const refused = [];
641
648
  const base = baseOf(tree);
642
649
  const walk2 = (dir) => {
643
650
  let entries;
@@ -653,7 +660,10 @@ function findDiskFiles(tree, pred) {
653
660
  try {
654
661
  st = statSync3(full);
655
662
  } catch (err) {
656
- recordSkip(tree, full, base, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
663
+ const why = `could not be read (${fsErrorCode(err)}) \u2014 not read`;
664
+ recordSkip(tree, full, base, why);
665
+ const relBad = relative2(base, full).split(sep2).join("/");
666
+ if (pred(name, relBad)) refused.push({ rel: relBad, reason: why });
657
667
  continue;
658
668
  }
659
669
  if (st.isDirectory()) {
@@ -666,13 +676,17 @@ function findDiskFiles(tree, pred) {
666
676
  const rel = relative2(base, full).split(sep2).join("/");
667
677
  if (!pred(name, rel)) continue;
668
678
  if (!st.isFile()) {
669
- recordSkip(tree, full, base, "not a regular file (FIFO, socket or device) \u2014 not read");
679
+ const why = "not a regular file (FIFO, socket or device) \u2014 not read";
680
+ recordSkip(tree, full, base, why);
681
+ refused.push({ rel, reason: why });
670
682
  continue;
671
683
  }
672
684
  try {
673
685
  accessSync2(full, constants2.R_OK);
674
686
  } catch (err) {
675
- recordSkip(tree, full, base, `could not be read (${fsErrorCode(err)}) \u2014 not read`);
687
+ const why = `could not be read (${fsErrorCode(err)}) \u2014 not read`;
688
+ recordSkip(tree, full, base, why);
689
+ refused.push({ rel, reason: why });
676
690
  continue;
677
691
  }
678
692
  out.push({ path: full, rel, name });
@@ -680,7 +694,7 @@ function findDiskFiles(tree, pred) {
680
694
  }
681
695
  };
682
696
  walk2(base);
683
- return out;
697
+ return Object.assign(out, { refused });
684
698
  }
685
699
  function readText(path) {
686
700
  try {
@@ -1710,12 +1724,29 @@ var a2DeclaredDomains = {
1710
1724
  }
1711
1725
  }
1712
1726
  const configs = findDiskFiles(tree, (n) => /^vite\.config\.(ts|js|mjs|cjs|mts)$/.test(n));
1713
- if (configs.length === 0) {
1727
+ let configUnread = false;
1728
+ for (const r of configs.refused) {
1729
+ configUnread = true;
1730
+ unreadable.push({
1731
+ anchor: r.rel,
1732
+ reason: `${r.rel} ${r.reason}: how the UI bundle is built (single file or not) is unread`
1733
+ });
1734
+ }
1735
+ const texts2 = configs.map((c) => ({ rel: c.rel, text: readText(c.path) }));
1736
+ for (const t of texts2) {
1737
+ if (t.text !== null) continue;
1738
+ configUnread = true;
1739
+ unreadable.push({
1740
+ anchor: t.rel,
1741
+ reason: `${t.rel} could not be read \u2014 how the UI bundle is built (single file or not) is unread`
1742
+ });
1743
+ }
1744
+ if (configs.length === 0 && configs.refused.length === 0) {
1714
1745
  unreadable.push({
1715
1746
  anchor: ui[0].anchor,
1716
1747
  reason: "no vite.config.* in the tree \u2014 how the UI bundle is built (single file or not) is unread"
1717
1748
  });
1718
- } else if (!configs.some((c) => SINGLEFILE_RE.test(stripComments(readText(c.path) ?? "")))) {
1749
+ } else if (!configUnread && !texts2.some((t) => SINGLEFILE_RE.test(stripComments(t.text ?? "")))) {
1719
1750
  reds.push({
1720
1751
  anchor: configs[0].rel,
1721
1752
  text: `${configs[0].rel} builds the UI without a single-file plugin (vite-plugin-singlefile or an inlining equivalent): the bundle is multi-file`
@@ -2632,11 +2663,14 @@ function productSites(tree, re) {
2632
2663
  }
2633
2664
  function diskText(tree, pred) {
2634
2665
  const out = [];
2635
- for (const f of findDiskFiles(tree, pred)) {
2666
+ const found = findDiskFiles(tree, pred);
2667
+ const refused = [...found.refused];
2668
+ for (const f of found) {
2636
2669
  const text = readText(f.path);
2637
2670
  if (text !== null) out.push({ ...f, text });
2671
+ else refused.push({ rel: f.rel, reason: "could not be read \u2014 not read" });
2638
2672
  }
2639
- return out;
2673
+ return Object.assign(out, { refused });
2640
2674
  }
2641
2675
  function governingWorkflows(tree) {
2642
2676
  let dir = baseOf(tree);
@@ -2755,6 +2789,12 @@ function appSurface(tree) {
2755
2789
  const anchor = uiSites[0]?.anchor ?? (uiFiles[0] ? `${uiFiles[0].rel}:1` : `${html[0].rel}:1`);
2756
2790
  return { anchor, uiFiles };
2757
2791
  }
2792
+ function unreadHtmlShells(tree) {
2793
+ return refusedAsUnreadable(
2794
+ findDiskFiles(tree, (name) => /\.html?$/i.test(name)).refused,
2795
+ "whether the tree is an App (UI code held in an HTML shell) is unread"
2796
+ );
2797
+ }
2758
2798
  function codeOnly(text) {
2759
2799
  return text.replace(/(["'`])((?:\\.|(?!\1)[^\\\n])*)\1(?!\s*:)/g, (_m, q) => `${q}${q}`);
2760
2800
  }
@@ -2814,10 +2854,14 @@ var a3SandboxRespected = {
2814
2854
  }
2815
2855
  const app = appSurface(tree);
2816
2856
  if (app === null) {
2817
- return absentResult(
2857
+ return settle(
2818
2858
  this.id,
2819
- "-",
2820
- "no UI code, no HTML and no ui:// resource in the tree (positive control OK) \u2014 A-3 n/a"
2859
+ absentResult(
2860
+ this.id,
2861
+ "-",
2862
+ "no UI code, no HTML and no ui:// resource in the tree (positive control OK) \u2014 A-3 n/a"
2863
+ ),
2864
+ unreadHtmlShells(tree)
2821
2865
  );
2822
2866
  }
2823
2867
  const uiRels = new Set(app.uiFiles.map((f) => f.rel));
@@ -2831,7 +2875,11 @@ var a3SandboxRespected = {
2831
2875
  inlineScripts.push({ anchor: `${file.rel}:${line}`, body: m[1] });
2832
2876
  }
2833
2877
  }
2834
- if (uiRels.size === 0 && htmlShells.length === 0 && inlineScripts.length === 0) {
2878
+ const unreadShells = refusedAsUnreadable(
2879
+ htmlShells.refused,
2880
+ "its <script> blocks (parent-frame access, direct storage) are unread"
2881
+ );
2882
+ if (uiRels.size === 0 && htmlShells.length === 0 && inlineScripts.length === 0 && unreadShells.length === 0) {
2835
2883
  return couldNotJudge(
2836
2884
  this.id,
2837
2885
  app.anchor,
@@ -2867,17 +2915,25 @@ var a3SandboxRespected = {
2867
2915
  }
2868
2916
  }
2869
2917
  }
2918
+ const unreadHtml = refusedAsUnreadable(
2919
+ htmlFiles.refused,
2920
+ "its <script> blocks (parent-frame access, direct storage) are unread"
2921
+ );
2870
2922
  if (reds.length > 0) {
2871
- return redResult(
2923
+ return settle(
2872
2924
  this.id,
2873
- reds[0].split(" ")[0],
2874
- `${reds.join("; ")} (A-3, positive-control OK)`
2925
+ redResult(this.id, reds[0].split(" ")[0], `${reds.join("; ")} (A-3, positive-control OK)`),
2926
+ unreadHtml
2875
2927
  );
2876
2928
  }
2877
- return presentResult(
2929
+ return settle(
2878
2930
  this.id,
2879
- app.anchor,
2880
- `${uiRels.size} UI file(s), ${htmlFiles.length} HTML shell(s) and ${inlineScripts.length} inline script(s) in strings: no parent-frame access, no direct storage \u2014 everything is left to the bridge (positive-control OK); the double-iframe CSP is the host proxy's (A-3)`
2931
+ presentResult(
2932
+ this.id,
2933
+ app.anchor,
2934
+ `${uiRels.size} UI file(s), ${htmlFiles.length} HTML shell(s) and ${inlineScripts.length} inline script(s) in strings: no parent-frame access, no direct storage \u2014 everything is left to the bridge (positive-control OK); the double-iframe CSP is the host proxy's (A-3)`
2935
+ ),
2936
+ unreadHtml
2881
2937
  );
2882
2938
  }
2883
2939
  };
@@ -2903,13 +2959,18 @@ var a4WcagGate = {
2903
2959
  }
2904
2960
  const app = appSurface(tree);
2905
2961
  if (app === null) {
2906
- return absentResult(
2962
+ return settle(
2907
2963
  this.id,
2908
- "-",
2909
- "not an App (no UI code, no HTML, no ui:// resource; positive control OK) \u2014 A-4 n/a"
2964
+ absentResult(
2965
+ this.id,
2966
+ "-",
2967
+ "not an App (no UI code, no HTML, no ui:// resource; positive control OK) \u2014 A-4 n/a"
2968
+ ),
2969
+ unreadHtmlShells(tree)
2910
2970
  );
2911
2971
  }
2912
- const noUiCode = app.uiFiles.length === 0 && findDiskFiles(tree, (name) => /\.html?$/i.test(name)).length === 0;
2972
+ const htmlShells = findDiskFiles(tree, (name) => /\.html?$/i.test(name));
2973
+ const noUiCode = app.uiFiles.length === 0 && htmlShells.length === 0;
2913
2974
  const pkgs = diskText(tree, (name) => name === "package.json");
2914
2975
  const unreadable = [];
2915
2976
  let dep = null;
@@ -2934,11 +2995,17 @@ var a4WcagGate = {
2934
2995
  const workflowRun = governingWorkflows(tree).find(
2935
2996
  (w) => /\baxe\b|axe-core/i.test(stripYamlComments(w.text))
2936
2997
  );
2937
- const testRun = diskText(tree, (name) => /\.(?:test|spec)\.[jt]sx?$/.test(name)).find(
2998
+ const testFiles = diskText(tree, (name) => /\.(?:test|spec)\.[jt]sx?$/.test(name));
2999
+ const testRun = testFiles.find(
2938
3000
  (f) => /\baxe\b|axe-core|jest-axe|vitest-axe/i.test(stripJsComments(f.text))
2939
3001
  );
3002
+ const refusedInputs = [
3003
+ ...refusedAsUnreadable(pkgs.refused, "its axe dependency and scripts are unread"),
3004
+ ...refusedAsUnreadable(testFiles.refused, "a test file that may run axe is unread"),
3005
+ ...refusedAsUnreadable(htmlShells.refused, "an HTML shell of the App is unread")
3006
+ ];
2940
3007
  const run = scriptRun ?? (workflowRun ? workflowRun.rel : null) ?? (testRun ? testRun.rel : null);
2941
- if (noUiCode && dep === null && run === null && unreadable.length === 0) {
3008
+ if (noUiCode && dep === null && run === null && unreadable.length === 0 && refusedInputs.length === 0) {
2942
3009
  return couldNotJudge(
2943
3010
  this.id,
2944
3011
  app.anchor,
@@ -2952,6 +3019,13 @@ var a4WcagGate = {
2952
3019
  `${unreadable.join(", ")} is present but could not be parsed (not valid JSON): the axe dependency and the scripts it lists are unreadable, so neither "no gate" nor "a gate" can be said${run === null ? "" : ` (a run was found at ${run})`}; owed: a parsable manifest, then an axe gate that CI runs with 0 blocking WCAG AA violations (A-4)`
2953
3020
  );
2954
3021
  }
3022
+ if (run === null && refusedInputs.length > 0) {
3023
+ return couldNotJudge(
3024
+ this.id,
3025
+ `${refusedInputs[0].anchor}:1`,
3026
+ `${refusedInputs.map((u) => u.reason).join("; ")}: neither "no gate" nor "an axe dependency that nothing runs" can be said over an input that was not read${dep === null ? "" : ` (${dep} is declared)`}; owed: a readable input, then an axe gate that CI runs with 0 blocking WCAG AA violations (A-4)`
3027
+ );
3028
+ }
2955
3029
  if (dep === null && run === null) {
2956
3030
  return redResult(
2957
3031
  this.id,
@@ -3052,10 +3126,14 @@ var a5PerformanceBudgets = {
3052
3126
  }
3053
3127
  const app = appSurface(tree);
3054
3128
  if (app === null) {
3055
- return absentResult(
3129
+ return settle(
3056
3130
  this.id,
3057
- "-",
3058
- "not an App (no UI code, no HTML, no ui:// resource; positive control OK) \u2014 A-5 n/a"
3131
+ absentResult(
3132
+ this.id,
3133
+ "-",
3134
+ "not an App (no UI code, no HTML, no ui:// resource; positive control OK) \u2014 A-5 n/a"
3135
+ ),
3136
+ unreadHtmlShells(tree)
3059
3137
  );
3060
3138
  }
3061
3139
  const { bundles, unmeasured } = collect(baseOf(tree));
@@ -3244,7 +3322,14 @@ function codeBlockAfter(file, line, col, open, close) {
3244
3322
  function scanCapabilities(tree) {
3245
3323
  const caps = [];
3246
3324
  const unreadable = [];
3247
- for (const f of findDiskFiles(tree, (n) => n === "mcp.json" || n === "mcp-server.json")) {
3325
+ const manifests = findDiskFiles(tree, (n) => n === "mcp.json" || n === "mcp-server.json");
3326
+ for (const r of manifests.refused) {
3327
+ unreadable.push({
3328
+ anchor: r.rel,
3329
+ reason: `${r.rel} ${r.reason}: its declared capabilities are unread`
3330
+ });
3331
+ }
3332
+ for (const f of manifests) {
3248
3333
  const json = readJsonRecord(f.path);
3249
3334
  if (!json) {
3250
3335
  unreadable.push({
@@ -3445,7 +3530,14 @@ function collectNamespacedKeys(tree) {
3445
3530
  for (const site of codeSites(tree, META_BRACKET_RE)) {
3446
3531
  acc.keys.push({ key: site.match[2], anchor: site.anchor, context: "_meta", source: "code" });
3447
3532
  }
3448
- for (const f of findDiskFiles(tree, (n) => n === "mcp.json" || n === "mcp-server.json")) {
3533
+ const manifests = findDiskFiles(tree, (n) => n === "mcp.json" || n === "mcp-server.json");
3534
+ for (const r of manifests.refused) {
3535
+ acc.unreadable.push({
3536
+ anchor: r.rel,
3537
+ reason: `${r.rel} ${r.reason}: its capability keys are unread`
3538
+ });
3539
+ }
3540
+ for (const f of manifests) {
3449
3541
  const json = readJsonRecord(f.path);
3450
3542
  if (!json) {
3451
3543
  acc.unreadable.push({
@@ -3578,17 +3670,25 @@ function assertSkillsControl(id) {
3578
3670
  }
3579
3671
  }
3580
3672
  function readSkillFiles(tree) {
3581
- return findDiskFiles(tree, (n) => n === "SKILL.md").map((f) => {
3582
- const text = readText(f.path) ?? "";
3673
+ const found = findDiskFiles(tree, (n) => n === "SKILL.md");
3674
+ const files = [];
3675
+ const refused = [...found.refused];
3676
+ for (const f of found) {
3677
+ const text = readText(f.path);
3678
+ if (text === null) {
3679
+ refused.push({ rel: f.rel, reason: "could not be read \u2014 not read" });
3680
+ continue;
3681
+ }
3583
3682
  const parts = f.rel.split("/");
3584
- return {
3683
+ files.push({
3585
3684
  path: f.path,
3586
3685
  rel: f.rel,
3587
3686
  dir: parts[parts.length - 2] ?? "",
3588
3687
  name: frontmatterName(text),
3589
3688
  text
3590
- };
3591
- });
3689
+ });
3690
+ }
3691
+ return Object.assign(files, { refused });
3592
3692
  }
3593
3693
 
3594
3694
  // src/detectors/a8-ui-skills-disjoint.ts
@@ -3635,7 +3735,12 @@ var a8UiSkillsDisjoint = {
3635
3735
  });
3636
3736
  }
3637
3737
  }
3638
- for (const sf of readSkillFiles(tree)) {
3738
+ const skillFiles = readSkillFiles(tree);
3739
+ const unreadSkills = refusedAsUnreadable(
3740
+ skillFiles.refused,
3741
+ "its frontmatter (a ui:// reference) is unread"
3742
+ );
3743
+ for (const sf of skillFiles) {
3639
3744
  const fm = sf.text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
3640
3745
  const hit = fm?.[1].match(/ui:\/\/\S+/);
3641
3746
  if (hit)
@@ -3645,17 +3750,25 @@ var a8UiSkillsDisjoint = {
3645
3750
  });
3646
3751
  }
3647
3752
  if (reds.length > 0) {
3648
- return {
3649
- id: this.id,
3650
- anchor: reds[0].anchor,
3651
- state: "non-conforming",
3652
- verdict: `${reds.map((r) => r.text).join("; ")} -> skills are plain markdown under the Skills extension, UI resources live under ui://, the two sets never overlap (A-8)`
3653
- };
3753
+ return settle(
3754
+ this.id,
3755
+ {
3756
+ id: this.id,
3757
+ anchor: reds[0].anchor,
3758
+ state: "non-conforming",
3759
+ verdict: `${reds.map((r) => r.text).join("; ")} -> skills are plain markdown under the Skills extension, UI resources live under ui://, the two sets never overlap (A-8)`
3760
+ },
3761
+ unreadSkills
3762
+ );
3654
3763
  }
3655
- return couldNotJudge(
3764
+ return settle(
3656
3765
  this.id,
3657
- surface.anchor,
3658
- "no source literal mixes the two spaces; owed (dynamic): skills/list and the ui:// resource list, compared \u2014 no manifest uri uses the UI scheme or equals a listed UI resource, no skills/list entry starts with ui:// (A-8)"
3766
+ couldNotJudge(
3767
+ this.id,
3768
+ surface.anchor,
3769
+ "no source literal mixes the two spaces; owed (dynamic): skills/list and the ui:// resource list, compared \u2014 no manifest uri uses the UI scheme or equals a listed UI resource, no skills/list entry starts with ui:// (A-8)"
3770
+ ),
3771
+ unreadSkills
3659
3772
  );
3660
3773
  }
3661
3774
  };
@@ -5036,7 +5149,14 @@ function sameFileConstObject(file, name) {
5036
5149
  }
5037
5150
  function ceilingCensus(tree) {
5038
5151
  const census = { readable: [], partial: [], unreadable: [] };
5039
- for (const f of findDiskFiles(tree, (n) => SERVER_MANIFEST_NAMES.has(n))) {
5152
+ const manifests = findDiskFiles(tree, (n) => SERVER_MANIFEST_NAMES.has(n));
5153
+ for (const r of manifests.refused) {
5154
+ census.unreadable.push({
5155
+ anchor: r.rel,
5156
+ reason: `${r.rel} ${r.reason}: a contextCeiling it may declare is unread`
5157
+ });
5158
+ }
5159
+ for (const f of manifests) {
5040
5160
  const text = readText(f.path);
5041
5161
  if (text === null) {
5042
5162
  census.unreadable.push({ anchor: f.rel, reason: `${f.rel} is not readable as text` });
@@ -6722,14 +6842,21 @@ var c24AcceptanceAtDispatch = {
6722
6842
  `C24 positive control FAILED: ${JSON.stringify(ctl)} \u2014 the dispatch matcher cannot tell an instrument job from another, verdict untrustworthy`
6723
6843
  );
6724
6844
  }
6725
- const docs = diskText(tree, (_name, rel) => DISPATCH_PATH_RE.test(rel)).filter(
6726
- (d) => INSTRUMENT_RE.test(d.text) && ACTION_RE.test(d.text)
6845
+ const candidates = diskText(tree, (_name, rel) => DISPATCH_PATH_RE.test(rel));
6846
+ const docs = candidates.filter((d) => INSTRUMENT_RE.test(d.text) && ACTION_RE.test(d.text));
6847
+ const unreadDocs = refusedAsUnreadable(
6848
+ candidates.refused,
6849
+ "it may be an instrument dispatch document, so it is unread"
6727
6850
  );
6728
6851
  if (docs.length === 0) {
6729
- return absentResult(
6852
+ return settle(
6730
6853
  this.id,
6731
- "-",
6732
- "no instrument dispatch document (task / dispatch / brief / issue template naming a detector or conformance instrument and an action on it) in the tree \u2014 C-24 static half n/a; the gate half is the delivery gate's (positive control OK)"
6854
+ absentResult(
6855
+ this.id,
6856
+ "-",
6857
+ "no instrument dispatch document (task / dispatch / brief / issue template naming a detector or conformance instrument and an action on it) in the tree \u2014 C-24 static half n/a; the gate half is the delivery gate's (positive control OK)"
6858
+ ),
6859
+ unreadDocs
6733
6860
  );
6734
6861
  }
6735
6862
  const lacking = docs.filter((d) => !hasAcceptanceBlock(d.text));
@@ -7761,7 +7888,10 @@ var c29ServedSkills = {
7761
7888
  const missing = [...methods].filter(([, a]) => a.length === 0).map(([m]) => m);
7762
7889
  const reds = [];
7763
7890
  const helpers = helperImports(tree, /skill/i);
7764
- const unreadable = [...surface.unreadable];
7891
+ const unreadable = [
7892
+ ...surface.unreadable,
7893
+ ...refusedAsUnreadable(skillFiles.refused, "its name was not compared to its directory")
7894
+ ];
7765
7895
  if (missing.length > 0) {
7766
7896
  if (helpers.length > 0) {
7767
7897
  unreadable.push({
@@ -8180,14 +8310,18 @@ var c31PluginInstallable = {
8180
8310
  tree,
8181
8311
  (_n, rel) => rel === ".claude-plugin/plugin.json" || rel.endsWith("/.claude-plugin/plugin.json")
8182
8312
  );
8313
+ const refusedManifests = refusedAsUnreadable(
8314
+ manifests.refused,
8315
+ "this plugin folder's manifest is unread, so it was not judged"
8316
+ );
8183
8317
  if (manifests.length === 0) {
8184
8318
  return couldNotJudge(
8185
8319
  this.id,
8186
- tree.root,
8187
- `no plugin folder could be located under ${tree.root} (a directory holding .claude-plugin/plugin.json) \u2014 whether the object installs as a Claude plugin is unjudged (C-31)`
8320
+ refusedManifests[0]?.anchor ?? tree.root,
8321
+ refusedManifests.length > 0 ? `${refusedManifests.map((u) => u.reason).join("; ")} \u2014 whether the object installs as a Claude plugin is unjudged (C-31)` : `no plugin folder could be located under ${tree.root} (a directory holding .claude-plugin/plugin.json) \u2014 whether the object installs as a Claude plugin is unjudged (C-31)`
8188
8322
  );
8189
8323
  }
8190
- const out = { reds: [], unreadable: [] };
8324
+ const out = { reds: [], unreadable: [...refusedManifests] };
8191
8325
  for (const m of manifests) judgeFolder(tree, dirname4(dirname4(m.path)), m.path, out);
8192
8326
  const anchor = manifests[0].rel;
8193
8327
  if (out.reds.length > 0) {
@@ -8657,9 +8791,10 @@ function sdkV2Range(tree) {
8657
8791
  }
8658
8792
  return null;
8659
8793
  }
8794
+ var isManifest = (n) => n === "mcp.json" || n === "mcp-server.json";
8660
8795
  function triggers(tree) {
8661
8796
  const out = [];
8662
- for (const f of findDiskFiles(tree, (n) => n === "mcp.json" || n === "mcp-server.json")) {
8797
+ for (const f of findDiskFiles(tree, isManifest)) {
8663
8798
  const j = readJsonRecord(f.path);
8664
8799
  if (!j || isRecord(j.mcpServers)) continue;
8665
8800
  const v = j.minProtocolVersion;
@@ -8732,12 +8867,19 @@ var d5 = {
8732
8867
  verdict: `no server/discover handler (grep=0, positive-control OK) although this server targets ${TARGET}: ${why.join("; ")} -> respond to server/discover with supportedVersions and capabilities, and announce the same capability set as initialize (S-4)`
8733
8868
  };
8734
8869
  }
8735
- return {
8736
- id: this.id,
8737
- anchor: "-",
8738
- state: "absent",
8739
- verdict: "no server/discover handler (grep=0, positive-control OK); no closed trigger found (no manifest minProtocolVersion >= 2026-07-28, no events capability, no host extension) -> S-4 does not oblige; add via protocol-kit when targeting 2026-07-28"
8740
- };
8870
+ return settle(
8871
+ this.id,
8872
+ {
8873
+ id: this.id,
8874
+ anchor: "-",
8875
+ state: "absent",
8876
+ verdict: "no server/discover handler (grep=0, positive-control OK); no closed trigger found (no manifest minProtocolVersion >= 2026-07-28, no events capability, no host extension) -> S-4 does not oblige; add via protocol-kit when targeting 2026-07-28"
8877
+ },
8878
+ refusedAsUnreadable(
8879
+ findDiskFiles(tree, isManifest).refused,
8880
+ "its minProtocolVersion (the trigger of S-4) is unread"
8881
+ )
8882
+ );
8741
8883
  }
8742
8884
  };
8743
8885
 
@@ -13247,15 +13389,22 @@ var e11SurfacesDerived = {
13247
13389
  );
13248
13390
  }
13249
13391
  const manifests = findDiskFiles(tree, (n) => n === "extension.json");
13392
+ const unreadable = refusedAsUnreadable(
13393
+ manifests.refused,
13394
+ "its surfaces are unread"
13395
+ );
13250
13396
  if (manifests.length === 0) {
13251
- return {
13252
- id: this.id,
13253
- anchor: "-",
13254
- state: "absent",
13255
- verdict: "no extension.json in the tree: the object is not an extension; E-11 n/a"
13256
- };
13397
+ return settle(
13398
+ this.id,
13399
+ {
13400
+ id: this.id,
13401
+ anchor: "-",
13402
+ state: "absent",
13403
+ verdict: "no extension.json in the tree: the object is not an extension; E-11 n/a"
13404
+ },
13405
+ unreadable
13406
+ );
13257
13407
  }
13258
- const unreadable = [];
13259
13408
  const reds = [];
13260
13409
  let judged = 0;
13261
13410
  for (const m of manifests) {
@@ -13343,9 +13492,10 @@ var e11SurfacesDerived = {
13343
13492
 
13344
13493
  // src/detectors/e12-host-capability-placement.ts
13345
13494
  var TARGET2 = "2026-07-28";
13495
+ var isManifest2 = (n) => n === "mcp.json" || n === "mcp-server.json";
13346
13496
  function targets2026(tree) {
13347
13497
  if (codeSites(tree, /["'`]2026-07-28["'`]/g).length > 0) return true;
13348
- for (const f of findDiskFiles(tree, (n) => n === "mcp.json" || n === "mcp-server.json")) {
13498
+ for (const f of findDiskFiles(tree, isManifest2)) {
13349
13499
  const j = readJsonRecord(f.path);
13350
13500
  if (!j || isRecord(j.mcpServers)) continue;
13351
13501
  if (typeof j.minProtocolVersion === "string" && j.minProtocolVersion >= TARGET2) return true;
@@ -13402,10 +13552,17 @@ var e12HostCapabilityPlacement = {
13402
13552
  );
13403
13553
  }
13404
13554
  if (targetUnknown) {
13405
- return couldNotJudge(
13555
+ return settle(
13406
13556
  this.id,
13407
- r.caps[0].anchor,
13408
- `${experimentalOnly.join(", ")} is announced only under capabilities.experimental, and nothing in this tree states that the server targets ${TARGET2} (no "${TARGET2}" literal, no manifest minProtocolVersion) \u2014 the placement rule applies only to such a server (E-12)`
13557
+ couldNotJudge(
13558
+ this.id,
13559
+ r.caps[0].anchor,
13560
+ `${experimentalOnly.join(", ")} is announced only under capabilities.experimental, and nothing in this tree states that the server targets ${TARGET2} (no "${TARGET2}" literal, no manifest minProtocolVersion) \u2014 the placement rule applies only to such a server (E-12)`
13561
+ ),
13562
+ refusedAsUnreadable(
13563
+ findDiskFiles(tree, isManifest2).refused,
13564
+ "its minProtocolVersion (the server's target) is unread"
13565
+ )
13409
13566
  );
13410
13567
  }
13411
13568
  return settle(
@@ -14559,6 +14716,10 @@ var s16LocalFootprint = {
14559
14716
  }
14560
14717
  const manifests = diskText(tree, (name) => MANIFESTS.has(name));
14561
14718
  const documented = manifests.some((m) => documentsFootprint(m.text));
14719
+ const unreadManifests = refusedAsUnreadable(
14720
+ manifests.refused,
14721
+ "it may document the file footprint, so it is unread"
14722
+ );
14562
14723
  const sensitive = productHits(tree, SENSITIVE_RE);
14563
14724
  const listens = productHits(tree, LISTEN_RE);
14564
14725
  const transports = productHits(tree, TRANSPORT_RE);
@@ -14576,7 +14737,10 @@ var s16LocalFootprint = {
14576
14737
  );
14577
14738
  }
14578
14739
  const reds = [];
14579
- if (!documented) {
14740
+ let footprintUnjudged = false;
14741
+ if (!documented && unreadManifests.length > 0) {
14742
+ footprintUnjudged = sensitive.length > 0 || homeReaders.length > 0;
14743
+ } else if (!documented) {
14580
14744
  for (const h of sensitive) {
14581
14745
  reds.push(
14582
14746
  `${h.anchor} names a sensitive path outside any declared object (${h.text.trim().slice(0, 60)}): a local server reads nothing outside its object unless the tool requires it and the manifest documents it`
@@ -14605,6 +14769,13 @@ var s16LocalFootprint = {
14605
14769
  `${reds.join("; ")} (S-16, positive-control OK)`
14606
14770
  );
14607
14771
  }
14772
+ if (footprintUnjudged) {
14773
+ return couldNotJudge(
14774
+ this.id,
14775
+ unreadManifests[0].anchor,
14776
+ `${unreadManifests.map((u) => u.reason).join("; ")}; ${sensitive.length} sensitive path(s) and ${homeReaders.length} home-directory read(s) are in the tree, and whether the manifest documents their footprint is unread (S-16)`
14777
+ );
14778
+ }
14608
14779
  const stdio = transports.some((t) => /StdioServerTransport/.test(t.text));
14609
14780
  const http = transports.some((t) => !/StdioServerTransport/.test(t.text)) || listens.length > 0;
14610
14781
  const socketOnly = listens.length > 0 && listens.every((h) => SOCKET_RE.test(h.text.match(LISTEN_RE)?.[1] ?? ""));
@@ -16383,6 +16554,17 @@ var s34AccessRecheck = {
16383
16554
  );
16384
16555
  }
16385
16556
  }
16557
+ if (manifests.refused.length > 0) {
16558
+ const named = refusedAsUnreadable(
16559
+ manifests.refused,
16560
+ "its events.accessRecheckSeconds is unread"
16561
+ );
16562
+ return couldNotJudge(
16563
+ this.id,
16564
+ named[0].anchor,
16565
+ `${named.map((u) => u.reason).join("; ")}${seen.length > 0 ? ` (the readable manifests say: ${seen.join("; ")})` : ""}; the recheck window cannot be judged absent or non-positive over a manifest that was not read (S-34)`
16566
+ );
16567
+ }
16386
16568
  const where = manifests[0]?.rel ?? surface.declared.anchor;
16387
16569
  return {
16388
16570
  id: this.id,
@@ -16969,6 +17151,7 @@ child.stdout.on("data", (d) => {
16969
17151
  while ((i = buf.indexOf("\n")) >= 0) {
16970
17152
  const line = buf.slice(0, i); buf = buf.slice(i + 1);
16971
17153
  if (!line.trim()) continue;
17154
+ // (a) Not a filesystem read: an in-memory parse of one stdout line. A non-JSON line is legitimately not a JSON-RPC answer (a server may log to stdout); the answers it never gave surface as no-answer outcomes / the 5 s probe deadline, never as a silent pass.
16972
17155
  let msg; try { msg = JSON.parse(line); } catch { continue; }
16973
17156
  // A request or notification FROM the server carries a method: it is not an answer.
16974
17157
  if (msg.method !== undefined) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-doctor",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Static MCP conformance checker: audits an MCP server source tree against the MCP 2026-07-28 standard and emits a conformance-diff table (file:line | present | absent | non-conforming | verdict).",
5
5
  "type": "module",
6
6
  "main": "./dist/doctor.js",