@vantageos/mcp-doctor 0.6.0 → 0.8.0

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,216 @@
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.0 — 2026-10-11
7
+
8
+ Measured against the same standard pin as 0.7.0, `b714129a110237c5521d5269ee61ab919408a17f` (91
9
+ rules). 0.7.0 covered 64 of them; 0.8.0 adds a detector for each of the 27 left, so the registry
10
+ covers all of them:
11
+
12
+ ```
13
+ npx tsx scripts/standard-coverage.ts b714129a110237c5521d5269ee61ab919408a17f ../elpi-corp-std
14
+ -> 91/91 rules covered by a registered detector (pin b714129a110237c5521d5269ee61ab919408a17f)
15
+ ```
16
+
17
+ The 27 are derived from that script's `UNCOVERED` rows at 0.7.0, not listed from memory: **C-23 C-24
18
+ C-27 C-28 · S-8 S-9 S-10 S-11 S-13 S-14 S-15 S-16 S-17 S-18 S-19 S-20 S-21 S-22 S-23 S-24 S-25 S-26
19
+ S-27 · E-5 · A-3 A-4 A-5**. Every one has a RED test committed before its implementation and a bite
20
+ mutation on foreign material, one per form of the guarded thing, with its legitimate pole
21
+ (`scripts/v080-foreign-bite-probe.sh`, `npm run test:v080-foreign-bite`).
22
+
23
+ ### Added — one detector per uncovered rule
24
+ - **Trunk gates, static half read, gate half named as owed:** **C23** judgement-sites, **C24**
25
+ acceptance-at-dispatch, **C27** subpackage-types-in-isolation, **C28** deploy-failure-signal.
26
+ - **Server:** **S8** token-passthrough, **S9** confused-deputy-proxy, **S10** ssrf-guard, **S11**
27
+ result-contract, **S13** progress-cancel, **S14** version-negotiation, **S15** completion-security,
28
+ **S16** local-footprint, **S17** forward-auth, **S18** session-integrity, **S19** auth-url-open,
29
+ **S20** scope-minimization, **S21** stdio-proxy-sandbox, **S22** origin-validation, **S23**
30
+ protocol-version-header, **S24** tool-rate-limit, **S25** resource-discipline, **S26**
31
+ list-changed-parity, **S27** elicitation-floor.
32
+ - **Extension:** **E5** extension-isolation. **App:** **A3** sandbox-respected, **A4** wcag-axe-gate,
33
+ **A5** performance-budgets.
34
+ - A dynamique or gate row keeps the 0.7.0 semantics: red where the tree contradicts the rule, `absent`
35
+ only when the subject is provably absent (each matcher carries a positive control and a failed
36
+ control throws), otherwise `could-not-judge` naming the half that is owed. Nothing that has an unread
37
+ half returns `present`.
38
+ - Per-detector reading, limits and verdicts: README, "Detectors added in 0.8.0".
39
+
40
+ ### Changed — Argus rulings on 0.7.0 (PR #55)
41
+ 1. **An owed human verdict is a structured field, not a state and not a prefix.** There is no fifth
42
+ state. A `could-not-judge` result may carry `owedVerdict: { kind: "human", rule: "C-23", sites:
43
+ [{ anchor, element?, what }] }`; the runner refuses it on any other state or with zero sites. S-5
44
+ (i)/(j) use it in place of the `VERDICT DUE (C-23)` prefix (removed from the prose), and so do C-24,
45
+ C-28 and S-20. The human report prints an "owed human verdicts (N sites)" section; the new `--json`
46
+ flag prints `{ surface, exitCode, owedHumanVerdicts, results, skipped }` with the field in each
47
+ result.
48
+ 2. **S-5 (g) and (l) match a quoted key.** `"client_id_metadata_document_supported": true` (and the
49
+ single-quoted spelling, and the same for `authorization_response_iss_parameter_supported`) was a
50
+ false red; each spelling x each key x `true` / `false` is now a test.
51
+ 3. **A-11 recognises every literal form of the sandbox domain.** Any quoted `*.claudemcpcontent.com`
52
+ with a label in front of the suffix is a typed literal: any label (hex or not, any case, hyphens,
53
+ several labels), any quoting (double, single, a template with no interpolation), a scheme prefix,
54
+ and a literal joined to the suffix (`"vp" + ".claudemcpcontent.com"`). The bare suffix constant and
55
+ an interpolated label are not literals. One test per form.
56
+
57
+ ### Fixed — found by running the new detectors on foreign material
58
+ Each was a false positive or a false green found on `vantage-mcp-boilerplate` or `vantage-peers`,
59
+ turned into a RED test before the fix:
60
+ - **C-28**: a deploy named in a YAML comment is not a deploy step.
61
+ - **C-27**: a workflow is read for the commands its `run:` keys run (inline and block), not for every
62
+ line holding the word; an `echo`, a job name or a comment is not a type check or an install.
63
+ - **S-8, S-24**: a marker word inside a string (an error message, a description) is prose, not an
64
+ audience check or a rate guard; a quoted property key still counts.
65
+ - **S-10**: a blocklist written as CIDR pairs (`["10.0.0.0", 8]`, what `node:net` BlockList takes) states
66
+ the union; the two metadata hostnames are satisfied by name resolution; a host classification
67
+ (`startsWith("127.")` choosing http or https) is not address validation outside a file that reaches
68
+ out.
69
+ - **S-13**: a TypeScript method signature named `fetch(request: Request, ...)` is not an outbound call.
70
+ - **S-17**: `iss` is validated in the file that redeems the code, not anywhere in the tree.
71
+ - **S-22**: a quoted `"0.0.0.0"` in a blocklist constant is not a bind; CORS `origin: "*"` is not an
72
+ Origin validation.
73
+ - **S-25**: an interpolated `${proto}://${host}` is a computed URL, not a custom scheme.
74
+ - **A-3, A-4**: a tree that serves `ui://` resources and holds no UI code is `could-not-judge`, not a
75
+ verdict on code that is not there.
76
+
77
+ ### Revised after review (PR #56, Argus REVISE at `b60df486`)
78
+ **The exit-0 pole is reachable.** `computeExitCode` returned 2 on any `could-not-judge`, and six new rules
79
+ had no way to say `present`, so no server that registered a tool could be approved. Each rule now declares
80
+ its choice, in its detector header and here:
81
+ - **S-11, S-13, S-16, A-4, A-5, A-3 — choice (b), a static positive path on positive evidence.** `present`
82
+ names the half it read (`static half: ...`) and the half it did NOT read (`NOT read by this run: ...`);
83
+ nothing is claimed about the unread half.
84
+ - S-11: every registered tool declares an `outputSchema` and no error code contradicts; a tool with none
85
+ whose window shows no result shape is `could-not-judge`, named.
86
+ - S-13: every outbound call carries a timeout (or none is emitted), no stale id, no answering cancel handler.
87
+ - S-16: no read outside the object, and a transport posture is identified (stdio, HTTP with a token check, a
88
+ Unix socket); tools with no identifiable transport are `could-not-judge`.
89
+ - A-4: an axe dependency and something that runs it; the CI result stays the gate's.
90
+ - A-5: the measured gzipped size is under 250 KB; with no built bundle in the tree it is `could-not-judge`.
91
+ - A-3: UI code, HTML shells and HTML held in a string literal (`ui://` served from `text: \`<html>\``) are
92
+ read; `ui://` served with no UI code anywhere stays `could-not-judge` (the code is elsewhere).
93
+ - S-14 (not in the review's list, same defect, found by the green-pole test): an SDK-first server whose
94
+ tree shows the official SDK (import or dependency) is `present` on its static half; a server class with
95
+ no SDK evidence stays `could-not-judge`.
96
+ - **S-20 — choice (c), an owed human verdict.** A result whose only open question is the human verdict on the
97
+ exposed sites declares `owedVerdict.mechanicalHalfComplete: true`. **Exit-code treatment, defined and
98
+ tested (`tests/v080-exit-code-owed.test.ts`):** such a result is OWED, not unreadable. It does not raise
99
+ exit 2 (exit 0 when nothing else does) and its sites stay printed and counted in `owedHumanVerdicts`. A
100
+ `could-not-judge` with no such declaration (S-5, whose probes are also owed) exits 2 as before, and a
101
+ violation still exits 1. Not defined: S-23 and S-10 on an SDK HTTP server still end in `could-not-judge`
102
+ (the SDK version that validates the header is not readable from the tree) — they hold exit 2 for such a tree.
103
+ - `reconcile()` (the `--mode=both` divergence) now carries the owed sites of either side.
104
+ - `fixtures/oracle-green-conforming` is repaired to conform to the 0.8.0 rules it failed: a real token-bucket
105
+ rate guard on the tool path (S-24), a stdio transport, a bridge-only UI shell, an axe gate, a built bundle.
106
+ A test asserts it exits 0 statically with S-11/13/16/24 and A-3/4/5 `present`, and exits 1 once the guard
107
+ is removed.
108
+
109
+ **Two security detectors no longer fail open.**
110
+ - **S-8** read the incoming Authorization header only through `.get("authorization")` or a quoted bracket, so
111
+ `authorization: c.req.raw.headers.authorization` forwarded to `fetch` read `present`. The census of reads
112
+ (a call, a bracket, DOT access with or without optional chaining, a destructuring renamed or not, any case)
113
+ is one test per form.
114
+ - **S-22** accepted a bare `allowedOrigins:` as protection. It is now read by VALUE: a wildcard (bare, in a
115
+ list, as a host pattern) and an empty list are red; a list of concrete origins is `present`; a variable is
116
+ resolved in the tree (wildcard / empty / concrete) or reported `could-not-judge` naming it; the protection
117
+ flag with no list is red. One test per shape.
118
+
119
+ **Sweep.**
120
+ - Source the scan does not read (`.js .mjs .cjs .jsx .mts .cts`; declarations, tests, config and
121
+ `_generated/` excluded): the code-reading rules (C-23, S-8..S-27, E-5, A-3) say `could-not-judge` naming the
122
+ files instead of `absent`. `src/bin/**` is product source (only a top-level `bin/` is tooling), and tools
123
+ registered by tooling are not the server's.
124
+ - `present` on a word: S-9 (needs a consent lookup, an equality test on `redirect_uri`, a random state), S-20
125
+ (a bare challenge declares no scope), S-24 (a rate-limit identifier must be CALLED), S-17 (the issuer is a
126
+ word of the store key, not the letters of `permissionId`), S-16 (a `footprint` KEY with content, not the
127
+ word), E-5 (zero files read is `could-not-judge`).
128
+ - Comment text as evidence: C-23 (a genre is declared by a code literal or a comment opening with `genre:`),
129
+ A-4 (workflow and test-file comments are not a run).
130
+ - Silent skips: S-15 (case-clause handlers are read; unreadable sites are named), A-5 (oversize, dangling or
131
+ unreadable UI paths are `could-not-judge`, named), C-27 (an unreadable deployable directory is named).
132
+
133
+ ### Process
134
+ - `tests/__oracle__/oracle-complete-server.static.json` is regenerated: 27 rows added, no existing row
135
+ changed.
136
+ - Version `0.8.0` is strictly above the live `latest` (`0.7.0`), read from the registry.
137
+
138
+ ## 0.7.0 — 2026-10-10
139
+
140
+ Measured against the ElPi-Corp standard pinned at `b714129a110237c5521d5269ee61ab919408a17f`
141
+ (91 rules; ElPi-Corp #243 on top of `2c4dedbe`). Every new or changed detector has a RED test
142
+ committed before its implementation, a bite mutation on foreign material and the legitimate form of
143
+ the same edit (`scripts/v070-foreign-bite-probe.sh`), and its restore proven (`diff -r` empty).
144
+
145
+ New rule ids, derived (`git diff 2c4dedbe b714129a` over the four standards, ids of the numbered
146
+ rules): **C-29 C-30 C-31 C-32 C-33 · S-29 S-30 S-31 S-32 S-33 S-34 · E-7 E-8 E-9 E-10 E-11 E-12 ·
147
+ A-8 A-9 A-10 A-11 A-12**, plus the **S-5 elements (f)..(l)**. The amended-in-place rules are C-2,
148
+ C-6 (debt), C-15, C-16, S-3, S-4, S-10, S-12, E-1, E-3, A-2, A-7.
149
+
150
+ ### Added — one detector per new rule
151
+ - **C29 served-skills**, **C30 skills-org-bound**, **C31 plugin-installable**, **C32
152
+ host-key-additive**, **C33 identity-tool**.
153
+ - **S29 events-declared**, **S30 event-descriptors**, **S31 subscription-contract**, **S32
154
+ callback-verification**, **S33 signed-delivery**, **S34 access-recheck**.
155
+ - **S5 authz-elements (f)-(l)** — PRM `resource` and suffixed route, CIMD + `"none"`, `iss`,
156
+ `/authorize` authenticating a person, jwt-bearer, OIDC discovery.
157
+ - **E7** middleware-events, **E8** official-ext-declared, **E9** host-invoked-tools, **E10**
158
+ mrtr-only, **E11** surfaces-derived, **E12** host-capability-placement.
159
+ - **A8** ui-skills-disjoint, **A9** entrypoint, **A10** display-modes, **A11**
160
+ sandbox-domain-derived, **A12** file-write.
161
+ - **What a dynamique row returns on a static run.** `absent` only when the subject is statically
162
+ proven absent (the matcher carries a positive control proving it can see the declaration); a red
163
+ where the tree contradicts the rule; otherwise `could-not-judge` naming the half that is owed —
164
+ never `present`, and never `absent` for a surface it found. The dynamic halves (a served
165
+ `tools/list`, two scoped identities, a webhook receiver) are not implemented; each detector's
166
+ header and verdict name what is owed.
167
+ - **`VERDICT DUE (C-23)`**: S-5 (i) and (j) are partly judgement. The detector exposes the sites and
168
+ returns `could-not-judge` with that prefix; the four states cannot say "sites exposed, verdict due",
169
+ and C-23 forbids `present` and `absent` there. Reported as a missing fifth state.
170
+
171
+ ### Added — amended rules, adapted in place
172
+ - **C-2**: a tool name over 64 characters is red (static and runtime names).
173
+ - **C-15**: a tool without a `title` (its own field, else `annotations.title`) is red; an enumerated
174
+ parameter holding a read verb and a write verb of the closed set (§3) is red.
175
+ - **C-16**: a `payloadSchema` string field named as a free body next to a read tool is red.
176
+ - **S-3**: when Skills is declared, `skills/list` and `skills/get` must carry `ttlMs`/`cacheScope`.
177
+ - **S-4**: `server/discover` is required on the closed trigger (manifest `minProtocolVersion >=
178
+ 2026-07-28`, the `events` capability, a host extension); with none of them the rule does not oblige.
179
+ An SDK v2 dependency (it serves `server/discover`) makes a missing literal `could-not-judge`.
180
+ - **E-1** and **`vendor-namespaces.json`** at the repository root (`openai/` first member, plus the
181
+ keys owned by another rule), shipped in `files` and read at the package's own version; an
182
+ unreadable registry is `could-not-judge`. A vendor key under `_meta`, `capabilities.extensions` or
183
+ `capabilities.experimental` is admitted only if its namespace is a member; an extension identifier
184
+ under a vendor namespace is red (we never publish under one).
185
+ - **E-3**: the fourth type, `host-capability-adapter`.
186
+ - **S-12**, **A-2**, **A-7** had no detector; they have one now (S-10 still has none: its only new
187
+ reading is a dynamic loopback receiver).
188
+ - **C-6 debt (named by the reviewer on #54)**: a log of an environment variable naming a URL /
189
+ endpoint / host, or of `process.cwd()`, is Form D. The class is swept: `os.homedir()`,
190
+ `os.tmpdir()`, `process.execPath`, `import.meta.dirname|filename|url`; `process.stderr|stdout.write`
191
+ counts as a log call; the value is followed through an identifier, a destructured binding and an
192
+ object property. A non-URL variable (`NODE_ENV`) and a read that is not logged stay green.
193
+
194
+ ### Changed
195
+ - Every detector now carries `rule` + `pin` = the standard pin above (the detectors that predate the
196
+ restructure were mapped to their current ids: D2/D12 S-1, D3 S-2, D15 S-6, D16 S-7, D6 A-6,
197
+ DServer8 A-1, DServer14 and DLocalIdentity S-5, DExt5 C-1, DExt7 C-5, DExt8 C-3, DExt9 E-6).
198
+ Re-pinning is not a retype: for each, the rule text is byte-identical between the detector's old
199
+ pin and the new one (C-17, C-18, C-25, C-26, S-28: identical between `2c4dedbe` and `b714129a`).
200
+ - `scripts/trunk-coverage.ts` is replaced by `scripts/standard-coverage.ts` (four standards, genre
201
+ column read from the detector table). `analysis/mcp-doctor-coverage-grid.md` is regenerated from it.
202
+ - Fixtures updated for rules that now require more: a `title` on every tool of the green C-15
203
+ fixtures; the oracle fixtures carry a plugin folder (C-31 is `could-not-judge` without one).
204
+ - A tool-method literal that is an object KEY (a cache-hints table) is not a handler.
205
+
206
+ ### Fixed (found by the foreign bite probe)
207
+ - A-2 credited a single-file plugin that was only NAMED in a comment; comments are now ignored.
208
+ - S-5 (f) followed only the first `resource:` near `authorization_servers`; every PRM site is judged
209
+ and a member read (`s.resource`) is followed to its definitions.
210
+
211
+ ### Measured (`--mode=static`, doctor 0.6.0 -> 0.7.0)
212
+ - `vantage-mcp-boilerplate` @ `cdb14add`: 10 -> 11 non-conforming, 2 -> 6 could-not-judge.
213
+ - `vantage-peers` `mcp-server` @ `9682a0d8`: 11 -> 14 non-conforming, 3 -> 7 could-not-judge.
214
+ - Every new non-conforming / could-not-judge line is named and classified in the pull request.
215
+
6
216
  ## 0.6.0 — 2026-10-10
7
217
 
8
218
  Rebased onto 0.5.0 (S-28, PR #53); this release ships as 0.6.0.
package/README.md CHANGED
@@ -46,7 +46,7 @@ The path is the root of the MCP server sources (the folder holding `server.ts` /
46
46
  The binary's own `--help` is the authority; this is what it prints:
47
47
 
48
48
  ```
49
- usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both] [--backend=<convex-dir>]
49
+ usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both] [--backend=<convex-dir>] [--json]
50
50
 
51
51
  --mode=static read the tree only (default)
52
52
  --mode=dynamic launch the server and judge what it serves
@@ -56,6 +56,7 @@ usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both] [--backend=<co
56
56
  fails (default 0)
57
57
  --backend=DIR the backend source (a convex/ dir) whose door list S-28 reads;
58
58
  default: the nearest convex/ above the tree
59
+ --json print the report as JSON (results, owedHumanVerdicts, exitCode)
59
60
  --version print the version and exit
60
61
  --help print this and exit
61
62
 
@@ -77,6 +78,11 @@ The exit code keeps three outcomes distinct, which is what makes the tool safe i
77
78
  - **1** — at least one violation.
78
79
  - **2** — no violation, but at least one detector abstained. **An abstention is never a pass**: it
79
80
  means part of the tree was not judged, and the row names which part and why.
81
+ **Exception, defined in 0.8.0:** a `could-not-judge` that carries an `owedVerdict` declaring
82
+ `mechanicalHalfComplete: true` is an OWED human verdict, not an abstention: the detector ran its whole
83
+ mechanical half and exposed the sites a person must decide (C-23: "green is: the sites are exposed"). It does
84
+ not raise exit 2; the sites stay printed and counted (`owedHumanVerdicts`). Any other `could-not-judge`, with
85
+ or without an `owedVerdict`, still exits 2.
80
86
 
81
87
  **One known limit in 0.3.2, stated rather than hidden:**
82
88
 
@@ -125,6 +131,105 @@ Each detector maps one standard item and is proven **bipolar** on real material:
125
131
  | **DLocalIdentity** no-local-identity | No MCP server defines its own identity primitive: resolving a caller to a tenant or agent (local JWT verify, a populated allow-list attached to a bearer), asserting org membership or role, filtering rows by caller scope (`fromAllowList`, a name allow-list, a row's `orgId` or acting-name field such as `createdBy` / `assignedTo` compared to the caller's, by `===`, `==`, `Object.is` or after `toLowerCase()`), or validating a credential (local `timingSafeEqual`, a digest of a presented value, HMAC; a hash over a static literal is not a site). A call to a binding imported from `@vantageos/cloud-identity` is not a site. Minting/signing sites (`new SignJWT`, `jwt.sign`, a private-key import) are listed as `MINTING/SIGNING` and not counted; verification and claim reads stay violations whoever issued the token (no issuer carve-out). | present (0, or EXEMPT) / non-conforming (count > ceiling, default 0) / could-not-judge |
126
132
  | **S-28** claim-forwarded | Server complement S-28 (standard `bacc54a`) — a tool forwards the verified claim of its caller (`verifiedOrg` / `verifiedPerson`) to every backend door it calls and refuses when it holds none. Reads each `<client>.query\|mutation\|action` call, the helpers it spreads, the refusal before it, and the backend source's door list (`--backend=<convex-dir>`, else the nearest `convex/` above the tree). A door that is service-account-only or declared `fleet-wide:` needs no claim; no backend found, an unknown door, a computed door name are could-not-judge. | present / non-conforming / could-not-judge / absent (no Convex client imported) |
127
133
 
134
+ ### Detectors added in 0.7.0 (standard `b714129a`, ElPi-Corp #243)
135
+
136
+ One detector per rule that #243 added, plus the S-5 elements (f)..(l), each implementing the
137
+ detector row of its rule. "Static run" is what the default mode returns; a dynamique row never
138
+ returns `present` statically — it returns `absent` only when its subject is proven absent, a red
139
+ where the tree contradicts the rule, and otherwise `could-not-judge` naming the half that is owed.
140
+
141
+ | ID | Rule | The static run reads | Verdicts |
142
+ |----|------|----------------------|----------|
143
+ | **C29** served-skills | C-29 | the `io.modelcontextprotocol/skills` declaration, `skills/list` + `skills/get` handlers, the `resources` capability, every `SKILL.md` `name` against its directory, a `"dynamic"` catalog manifest, `ttlMs`/`cacheScope` | absent (aloud, naming any unserved SKILL.md) / present + dynamic half owed / non-conforming |
144
+ | **C30** skills-org-bound | C-30 | whether Skills is declared | absent / could-not-judge (two scoped, non-master identities owed) |
145
+ | **C31** plugin-installable | C-31 | every `.claude-plugin/plugin.json` folder: kebab-case name, version equal to `package.json`, licence, README >= 40 words outside code, `.mcp.json` https `type: http` entries without a literal credential, pinned launchers, no top-level `bin/`, no path leaving the folder, no credential shape in README | present / non-conforming / could-not-judge (no plugin folder, unparseable file, env-built URL without default) |
146
+ | **C32** host-key-additive | C-32 | `_meta` objects (followed through spreads and consts): `openai/outputTemplate` needs `ui.resourceUri`, `openai/visibility` needs `ui.visibility` | absent / non-conforming / could-not-judge (served `tools/list` owed) |
147
+ | **C33** identity-tool | C-33 | on an authenticated server, the identity tool: no argument, `readOnlyHint: true`, an `outputSchema` that declares `id` (inline or a same-tree const) | absent / non-conforming / could-not-judge |
148
+ | **S29**..**S34** | S-29..S-34 | the `events` capability and the three `events/*` handler literals; descriptors (`name`, bilingual description, non-empty `delivery`, `inputSchema`, `payloadSchema`); the `-32012`/`-32602`/`whsec_`, `-32015`/`verification`, five-header/`standardwebhooks`/262144-byte/410-413 evidence; `events.accessRecheckSeconds > 0` in the server manifest | absent (no events surface) / non-conforming / could-not-judge (dynamic half named) |
149
+ | **S5** authz-elements (f)-(l) | S-5 (f)..(l) | PRM `resource` and its path-suffixed route, CIMD + `"none"`, `iss` flag, `/authorize` handler (auto-approval, organisation from a query parameter), jwt-bearer, OIDC discovery | absent / non-conforming / could-not-judge (sites exposed in `owedVerdict`; probes owed) |
150
+ | **E7** middleware-events | E-7 | writes to `delivery`/`arguments`/`id`/`refreshBefore`/`cursor` and replacement of `ctx.output` inside an `events/*` branch of a hook | absent / present / non-conforming |
151
+ | **E8** official-ext-declared | E-8 | `capabilities.extensions` as an object, each official key's MUST methods registered | absent / present / non-conforming / could-not-judge (unknown official extension) |
152
+ | **E9** host-invoked-tools | E-9 | `openai/settings.readTool`, `openai/mentions.searchTool`: registered, `readOnlyHint: true`, `outputSchema`, no required input, `"app"` visibility, ResourceLink | absent / present / non-conforming |
153
+ | **E10** mrtr-only | E-10 | per-request transport + `elicitInput` / `elicitation/create` | absent / present / non-conforming |
154
+ | **E11** surfaces-derived | E-11 | `extension.json.surfaces` against `bin/`, `hooks/`, `agents/`, local MCP commands | absent / present / non-conforming |
155
+ | **E12** host-capability-placement | E-12 | host capability under `extensions` rather than only `experimental` (for a 2026-07-28 server) + the E-9 contract | absent / present / non-conforming / could-not-judge |
156
+ | **A8** ui-skills-disjoint | A-8 | `ui://` literals in skill manifests or naming a skill file | absent / non-conforming / could-not-judge |
157
+ | **A9** entrypoint | A-9 | `_meta["openai/ui"].entrypoints`: `{}` input, title, `currentColor` icon, dotted file extensions, `ontoolresult` before `connect` | absent / present / non-conforming |
158
+ | **A10** display-modes | A-10 | `availableDisplayModes` of the resource against the App's, `pip` refused for ChatGPT | absent / present / non-conforming / could-not-judge |
159
+ | **A11** sandbox-domain-derived | A-11 | a `ui://` resource served over HTTP and a SHA-256 + `.claudemcpcontent.com` derivation | absent / non-conforming / could-not-judge |
160
+ | **A12** file-write | A-12 | `openai/resources/write`: `ifMatch`, `conflict` + `too-large` handled, received URI only | absent / present / non-conforming |
161
+
162
+ ### Detectors added in 0.8.0 (standard `b714129a`) — the 27 rules 0.7.0 left uncovered
163
+
164
+ One detector per remaining rule, so `scripts/standard-coverage.ts` derives **91/91**. Each keeps the
165
+ semantics of a dynamique row: red where the tree contradicts the rule, `absent` only when the subject
166
+ is provably absent (a positive control proves the matcher can see it), otherwise `could-not-judge`
167
+ naming the half that is owed. Comments never count; where a marker word could be prose (a description,
168
+ an error message) it counts only as code or as a quoted property key.
169
+
170
+ | ID | Rule | The static run reads | Verdicts |
171
+ |----|------|----------------------|----------|
172
+ | **C23** judgement-sites | C-23 | every control that declares a judgement genre (the declared column, split on `+`, is the authority): what it returns, whether the due sites travel in `owedVerdict`, a positive control beside any zero | absent / present (sites exposed) / non-conforming |
173
+ | **C24** acceptance-at-dispatch | C-24 | dispatch documents (`tasks/`, `dispatch/`, `briefs/`, issue templates) that build, extend or run a detector / conformance instrument, and the acceptance-property block in them (EN or FR) | absent / non-conforming / could-not-judge (the verdict-side equality is the owed gate; `owedVerdict`) |
174
+ | **C27** subpackage-types-in-isolation | C-27 | each deployable sub-package with a `tsconfig` (C-26's own derivation): a type check that runs after a FROZEN install, read from the Dockerfile `RUN` lines, the railway `buildCommand` and the CI `run:` commands | absent / non-conforming (none / before any install / after a non-frozen install) / could-not-judge (the run result is the owed gate) |
175
+ | **C28** deploy-failure-signal | C-28 | CI deploy steps (railway, vercel, fly, wrangler, netlify, serverless, gcloud run, kubectl, helm); an `if: failure()` step that notifies, or a served-identity-equals-commit check | absent / non-conforming / could-not-judge (a platform-managed deploy, or a signal present; that a person received it is the owed human verdict, `owedVerdict`) |
176
+ | **S8** token-passthrough | S-8 | token verifiers (audience in the same file for `jwtVerify` / `jwt.verify`, anywhere for a library verifier) and any outbound `Authorization` built from the caller's own header, `authInfo.token`, or a variable assigned from them | absent / present / non-conforming / could-not-judge |
177
+ | **S9** confused-deputy-proxy | S-9 | a proxy file (`/authorize` route, `redirect(`, an upstream): consent registry, `redirect_uri` compared exactly (not by `startsWith` / `includes` / `endsWith` / `match` / a regular expression), `__Host-` cookies, `state` after the consent check | absent / present / non-conforming |
178
+ | **S10** ssrf-guard | S-10 | outbound calls whose URL is a caller member (`args.url`, `delivery.url` ...), directly, aliased or destructured; hand-written address tests; `redirect: "follow"`; a blocklist that states at least three members of the union but not all (CIDR pairs count; the metadata hostnames are satisfied by name resolution) | absent / non-conforming / could-not-judge (the loopback-callback and 302 probes are owed) |
179
+ | **S11** result-contract | S-11 | tools returning `structuredContent` without an `outputSchema`; error codes in the reserved range -32020..-32099; a string `code` in a JSON-RPC error | absent / non-conforming / could-not-judge (served conformance owed) |
180
+ | **S13** progress-cancel | S-13 | outbound calls without a `signal` / `timeout`; a literal `requestId` / `progressToken` in an emitted notification; a cancellation handler that answers | absent / non-conforming / could-not-judge (progress monotony, late cancel, `initialize` owed) |
181
+ | **S14** version-negotiation | S-14 | a hand-rolled `initialize` answering a fixed `protocolVersion` | absent / non-conforming / could-not-judge (the negotiation probe is owed) |
182
+ | **S15** completion-security | S-15 | the `completions` capability, `completion/complete`, `completable(`: input validation and a rate limit in the handler | absent / non-conforming / could-not-judge (no-disclosure owed) |
183
+ | **S16** local-footprint | S-16 | reads of `~/.ssh`, cloud credentials, shell history; home-directory reads with no `footprint` in the manifest; HTTP bound to loopback with no token and no socket | absent / non-conforming / could-not-judge (the runtime file trace is owed) |
184
+ | **S17** forward-auth | S-17 | an authorization code redeemed without `iss` handled in the redeeming file; credentials keyed without the issuer; dynamic registration with no CIMD | absent / present / non-conforming |
185
+ | **S18** session-integrity | S-18 | `sessionIdGenerator`: predictable, not a CSPRNG, not bound `<user>:<id>`; a session lookup used as authentication | absent / present / non-conforming |
186
+ | **S19** auth-url-open | S-19 | URL opening through a shell, scheme validation by blocklist, no http(s) allowlist | absent / present / non-conforming |
187
+ | **S20** scope-minimization | S-20 | omnibus scopes, the whole catalogue in `scopes_supported`; the sites that check a CLAIMED scope | absent / present / non-conforming / could-not-judge (`owedVerdict`: does authorization rest on the claim alone) |
188
+ | **S21** stdio-proxy-sandbox | S-21 | child-process launches: a sandbox marker, a log within eight lines, an authorization word for a dangerous command | absent / present / non-conforming |
189
+ | **S22** origin-validation | S-22 | a Streamable HTTP transport: the SDK's DNS rebinding protection, or an `Origin` read followed by a 403; `0.0.0.0` named | absent / present / non-conforming |
190
+ | **S23** protocol-version-header | S-23 | the `MCP-Protocol-Version` read (any case) followed by a 400; a hand-rolled route that never reads it | absent / present / non-conforming / could-not-judge (the SDK owns the check; a probe is owed) |
191
+ | **S24** tool-rate-limit | S-24 | each registered tool's window for a rate-limit word as code (a quoted key counts), or a central `CallToolRequestSchema` handler that limits; each unguarded tool is named | absent / present / non-conforming |
192
+ | **S25** resource-discipline | S-25 | unvalidated dereference of a caller-derived value in a resource handler, a custom scheme that is not RFC 3986, `blob:` that is not base64, binary content without `mimeType` | absent / present / non-conforming |
193
+ | **S26** list-changed-parity | S-26 | `resources` / `prompts` declaring `listChanged: true` against the matching `list_changed` emission, per kind | absent / present / non-conforming |
194
+ | **S27** elicitation-floor | S-27 | sensitive property keys of a `requestedSchema`; MRTR `input_required` with no elicitation fallback and no floor declaration (`midCallInputAtFloor: false`) | absent / non-conforming / could-not-judge (the floor probe is owed) |
195
+ | **E5** extension-isolation | E-5 | global writes (`globalThis`, `process.env`, built-in prototypes), writes to an `identifier`, writes into the shared `extensions` map under a key that is not the extension's own | absent / present / non-conforming |
196
+ | **A3** sandbox-respected | A-3 | `window.parent`, `window.top`, `localStorage`, `sessionStorage`, `document.cookie` in UI code and in the `<script>` blocks of HTML shells | absent / present / non-conforming / could-not-judge (ui:// served, no UI code in the tree) |
197
+ | **A4** wcag-axe-gate | A-4 | an axe dependency and a script, workflow or test that runs it | absent / non-conforming / could-not-judge (0 blocking violations is the owed gate) |
198
+ | **A5** performance-budgets | A-5 | gzipped size of the UI bundle files (`dist/` and `build/` read for size only): one file or one directory over 250 KB | absent / non-conforming / could-not-judge (render, roundtrip, memory probes owed) |
199
+
200
+ **What `present` means for a rule whose other half is dynamic (0.8.0).** S-11, S-13, S-14, S-16, A-3, A-4
201
+ and A-5 return `present` on a static positive path: the verdict says `static half: ...` and names the half it
202
+ did NOT read (`NOT read by this run: ...`) — the served output conforms, progress is monotone, the negotiation
203
+ probe, the runtime file trace, the CI result of the axe run, render/roundtrip/memory. `--mode=dynamic` does not
204
+ implement those reads. Read `present` on these rows as "the static half holds", never as "the rule is held".
205
+
206
+ **An owed human verdict is a field, not a state.** There is no fifth state. A `could-not-judge` whose
207
+ owed half is judgement carries `owedVerdict: { kind: "human", rule: "C-23", sites: [{ anchor, element,
208
+ what }] }` (S-5 (i)/(j), C-24, C-28, S-20). The human report lists the sites in an "owed human verdicts"
209
+ section, counted; `--json` prints `{ surface, exitCode, owedHumanVerdicts, results, skipped }` and the
210
+ field is in each result. The `VERDICT DUE (C-23)` prose prefix is gone: a consumer counts owed
211
+ verdicts from the field, never from prose.
212
+
213
+ **S-5 (g) and (l)** match their key bare, double-quoted or single-quoted. **A-11** calls a typed
214
+ literal any quoted `*.claudemcpcontent.com` (any label, any case, any quoting, a scheme, several
215
+ labels, or a literal joined to the suffix); the bare suffix constant and an interpolated label are not
216
+ literals.
217
+
218
+ Amended rules, adapted in place: **C-2** (a tool name is at most 64 characters), **C-15** (a `title`,
219
+ and no enumerated parameter spanning a read and a write verb), **C-16** (event payloads), **S-3**
220
+ (`skills/list` and `skills/get`), **S-4** (the closed trigger: manifest `minProtocolVersion >=
221
+ 2026-07-28`, the `events` capability, a host extension), **E-1** (the vendor-namespace registry
222
+ `vendor-namespaces.json`, `openai/` first member, shipped in the package and read by the detector),
223
+ **E-3** (the fourth type, `host-capability-adapter`), and, with new detectors because they never had
224
+ one, **S-12**, **A-2** and **A-7**. **C-6** now flags a log of an environment variable that names a
225
+ URL / endpoint / host, and of `process.cwd()` and the other absolute-path sources, through
226
+ `console.*`, `logger.*` and `process.stderr|stdout.write`, directly or through an identifier, a
227
+ destructured binding or an object property.
228
+
229
+ The coverage grid (`analysis/mcp-doctor-coverage-grid.md`) is derived by
230
+ `npx tsx scripts/standard-coverage.ts <pin> <ElPi-Corp checkout>`; the foreign-material bite proof is
231
+ `npm run test:v070-foreign-bite` and `npm run test:v080-foreign-bite` (see `scripts/v070-foreign-bite-probe.sh` and `scripts/v080-foreign-bite-probe.sh`).
232
+
128
233
  **DLocalIdentity exemptions.** Two, both reported as `EXEMPT` rather than hidden: the
129
234
  `@vantageos/cloud-identity` package itself (read from the target's `package.json` name), and a site
130
235
  carrying `// allow-local-identity: <reason of 8 characters or more>` on the line, in the comment
package/dist/doctor.d.ts CHANGED
@@ -50,9 +50,47 @@ interface Tree {
50
50
  * so when it finds none. Unset means "not supplied", never "no backend".
51
51
  */
52
52
  backendRoot?: string;
53
+ /**
54
+ * Product-looking source the scan did NOT read (`.js` `.mjs` `.cjs` `.jsx` `.mts` `.cts`, relative paths;
55
+ * declarations, tests and config files excluded). A rule that says `absent` on the strength of the files
56
+ * it read must not say it while this list is non-empty: it says could-not-judge and names them.
57
+ */
58
+ unscannedSource?: string[];
53
59
  }
54
60
 
55
61
  type State = "present" | "absent" | "non-conforming" | "could-not-judge";
62
+ /**
63
+ * One site on which a human verdict is due (C-23): the mechanical half of the rule ran in full and
64
+ * the remaining question is judgement, which an instrument may not answer.
65
+ */
66
+ interface OwedVerdictSite {
67
+ /** file:line the verdict is about. */
68
+ anchor: string;
69
+ /** What the human must decide at that site, in one sentence. */
70
+ what: string;
71
+ /** The rule element the site belongs to, when the rule has lettered elements (e.g. "S-5 (i)"). */
72
+ element?: string;
73
+ }
74
+ /**
75
+ * "A human verdict is owed", as a STRUCTURED field of a could-not-judge result. There is NO fifth
76
+ * state: C-23's "sites exposed, verdict due" is a could-not-judge whose owed half is judgement, and
77
+ * a consumer counts owed verdicts by reading this field, never by scanning prose (derive-never-type).
78
+ * Only a could-not-judge may carry it, and never with zero sites (a zero is an event, not a rest).
79
+ */
80
+ interface OwedVerdict {
81
+ kind: "human";
82
+ /** The rule that makes the verdict owed: C-23 (a judgement control exposes its sites). */
83
+ rule: string;
84
+ /**
85
+ * True when the detector ran the whole mechanical half of its rule and the ONLY thing left is the
86
+ * human verdict on `sites` (C-23: "green is: the sites are exposed"). The exit code then treats the
87
+ * result as OWED, not unreadable: it does not raise exit 2, and the sites stay printed and counted
88
+ * (`owedHumanVerdicts`). Absent or false: something besides the human verdict is unread (a probe, a
89
+ * dynamic half), and the result exits 2 like any could-not-judge.
90
+ */
91
+ mechanicalHalfComplete?: boolean;
92
+ sites: OwedVerdictSite[];
93
+ }
56
94
  interface DetectorResult {
57
95
  id: string;
58
96
  /** display anchor(s) file:line — the site the verdict is about. */
@@ -65,6 +103,8 @@ interface DetectorResult {
65
103
  * and the runner refuses it. Ignored for the three decisive states.
66
104
  */
67
105
  reason?: string;
106
+ /** Present only on a could-not-judge whose owed half is a human verdict. See `OwedVerdict`. */
107
+ owedVerdict?: OwedVerdict;
68
108
  }
69
109
  /** A single tool as reported by the RUNNING server over the MCP `tools/list` call. */
70
110
  interface RuntimeTool {
@@ -134,7 +174,7 @@ interface Detector {
134
174
  * so the pre-existing detectors that predate this field still compile; every
135
175
  * detector built or re-pinned against a standard rule MUST populate it.
136
176
  * Coverage of the standard is DERIVED from this field across the registry
137
- * (see `scripts/trunk-coverage.ts`) — never from a hand-kept list.
177
+ * (see `scripts/standard-coverage.ts`) — never from a hand-kept list.
138
178
  */
139
179
  rule?: string;
140
180
  /** The standard commit this detector's `rule` was read against. Required
@@ -285,6 +325,20 @@ declare function runDoctorAsync(target: string, opts?: {
285
325
  */
286
326
  declare function renderSkipped(skipped: SkippedDir[]): string;
287
327
  declare function renderTable(surface: string, results: DetectorResult[], skipped?: SkippedDir[]): string;
328
+ /** Total owed human-verdict sites across the results — counted from the structured field. */
329
+ declare function countOwedHumanVerdicts(results: DetectorResult[]): number;
330
+ /**
331
+ * The owed-human-verdicts section: every site a human must decide, counted, read from the
332
+ * structured `owedVerdict` field. Empty string (section omitted) when none are owed — a count of
333
+ * zero is printed by the JSON output, which always carries it.
334
+ */
335
+ declare function renderOwedVerdicts(results: DetectorResult[]): string;
336
+ /**
337
+ * The machine-readable report (`--json`). The same results the table prints, plus the exit code and
338
+ * the owed-human-verdict count, so a consumer reads "a verdict is owed" from a field and never from
339
+ * prose.
340
+ */
341
+ declare function renderJson(surface: string, results: DetectorResult[], skipped: SkippedDir[]): string;
288
342
  /**
289
343
  * The process exit code a run's results carry, three-way with precedence:
290
344
  * 1 — a VIOLATION: at least one detector is `non-conforming` (something IS
@@ -297,4 +351,4 @@ declare function renderTable(surface: string, results: DetectorResult[], skipped
297
351
  */
298
352
  declare function computeExitCode(results: DetectorResult[]): number;
299
353
 
300
- export { type LiveDetector, type RunDoctorOptions, type RunMode, type Session, computeExitCode, isLiveDetector, renderSkipped, renderTable, runDoctor, runDoctorAsync, runDoctorReport };
354
+ export { type LiveDetector, type RunDoctorOptions, type RunMode, type Session, computeExitCode, countOwedHumanVerdicts, isLiveDetector, renderJson, renderOwedVerdicts, renderSkipped, renderTable, runDoctor, runDoctorAsync, runDoctorReport };