@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 +19 -0
- package/dist/doctor.js +256 -73
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
2857
|
+
return settle(
|
|
2818
2858
|
this.id,
|
|
2819
|
-
|
|
2820
|
-
|
|
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
|
-
|
|
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
|
|
2923
|
+
return settle(
|
|
2872
2924
|
this.id,
|
|
2873
|
-
reds[0].split(" ")[0],
|
|
2874
|
-
|
|
2925
|
+
redResult(this.id, reds[0].split(" ")[0], `${reds.join("; ")} (A-3, positive-control OK)`),
|
|
2926
|
+
unreadHtml
|
|
2875
2927
|
);
|
|
2876
2928
|
}
|
|
2877
|
-
return
|
|
2929
|
+
return settle(
|
|
2878
2930
|
this.id,
|
|
2879
|
-
|
|
2880
|
-
|
|
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
|
|
2962
|
+
return settle(
|
|
2907
2963
|
this.id,
|
|
2908
|
-
|
|
2909
|
-
|
|
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
|
|
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
|
|
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
|
|
3129
|
+
return settle(
|
|
3056
3130
|
this.id,
|
|
3057
|
-
|
|
3058
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
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
|
|
3764
|
+
return settle(
|
|
3656
3765
|
this.id,
|
|
3657
|
-
|
|
3658
|
-
|
|
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
|
-
|
|
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
|
|
6726
|
-
|
|
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
|
|
6852
|
+
return settle(
|
|
6730
6853
|
this.id,
|
|
6731
|
-
|
|
6732
|
-
|
|
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 = [
|
|
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,
|
|
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
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
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
|
-
|
|
13253
|
-
|
|
13254
|
-
|
|
13255
|
-
|
|
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,
|
|
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
|
|
13555
|
+
return settle(
|
|
13406
13556
|
this.id,
|
|
13407
|
-
|
|
13408
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|