@vantageos/mcp-doctor 0.8.0 → 0.8.2
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 +100 -0
- package/README.md +31 -2
- package/dist/doctor.d.ts +70 -1
- package/dist/doctor.js +1689 -479
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,106 @@
|
|
|
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.2 — 2026-10-25
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **A detector's own read that fails, or could block, is named instead of being judged empty** (task
|
|
10
|
+
k17dddv66dbzd1fsmz9bwg4ysx8gy6zv). 0.8.1 closed this for the target walk. Each detector's own reads
|
|
11
|
+
still had the same holes:
|
|
12
|
+
- a read failure answered by a bare `return` or `continue`;
|
|
13
|
+
- a `readFileSync` that would block forever on a FIFO.
|
|
14
|
+
|
|
15
|
+
Every site is now classified in `analysis/detector-read-class.md`: 87 rows, of which 43 are declared
|
|
16
|
+
legitimate (with a comment at the site) and 44 are converted. A converted site reports
|
|
17
|
+
`could-not-judge` naming what it did not read, or records a skip. A non-regular file is never read.
|
|
18
|
+
C-26 no longer aborts the run on an unreadable `package.json` or lockfile. D8 (dynamic) no longer
|
|
19
|
+
counts an unreadable `evals.json` as zero coverage.
|
|
20
|
+
|
|
21
|
+
On readable input every verdict is unchanged: four real trees give the same 101 results, the same
|
|
22
|
+
states and the same exit code on 0.8.1 and 0.8.2.
|
|
23
|
+
|
|
24
|
+
## 0.8.1 — 2026-10-13
|
|
25
|
+
|
|
26
|
+
Standard re-pinned to ElPi-Corp `6d463a1ecc5256cd59dd1afe5aa5e335accacc90`: the direct child of
|
|
27
|
+
`24c1ffc97375581c05c1e8058afb2faf78da602e` (mcp-standard v2.6) that adds S-35 and nothing else
|
|
28
|
+
(`gh api repos/elpiarthera/ElPi-Corp/compare/24c1ffc9...6d463a1e` -> ahead 1, behind 0). Coverage is
|
|
29
|
+
derived, never typed here:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
npx tsx scripts/standard-coverage.ts 6d463a1ecc5256cd59dd1afe5aa5e335accacc90 <ElPi-Corp checkout>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **S35** scope-in-read (S-35): a tool's scope predicate is applied inside the read it delegates to.
|
|
37
|
+
Reads every `<client>.query|action(<door>, <args>)` and the narrowing that follows it in the same
|
|
38
|
+
block, in each form the rule names: `scopeFilterList`, `scopeFilterGet`, a `.filter` reading an
|
|
39
|
+
organisation / namespace / recipient / owner field or the caller context, a skipping branch in a loop.
|
|
40
|
+
Red when the call's arguments carry no scope predicate; a door named by an expression, or arguments
|
|
41
|
+
spread from an unread value, is could-not-judge.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
- **C-11 (b), dynamic** (v2.6, SEP-1303): the introspector sends an invalid cursor to every protocol list
|
|
45
|
+
method the server declares (`-32602` required) and as the `cursor` argument of every served tool that
|
|
46
|
+
takes one (a tool result with `isError: true` and `structuredContent.error.code` `invalid_cursor`
|
|
47
|
+
required). A protocol error, a silent page, a missing or different code is red; no answer, or a tool
|
|
48
|
+
that requires another argument, is could-not-judge. The outcome domain is a typed union judged by an
|
|
49
|
+
exhaustive switch.
|
|
50
|
+
- **C-9** accepts the v2.6 read verb `prepare`; **C-15** partitions it as a read verb.
|
|
51
|
+
- **C-13** reads the context ceiling in every declaration form: the server manifest's `contextCeiling`
|
|
52
|
+
(`mcp.json` / `mcp-server.json`) and a `CONTEXT_CEILING` / `contextCeiling` const or field, inline or
|
|
53
|
+
behind a same-file const. A form present but unreadable (a manifest that does not parse, a computed
|
|
54
|
+
value) is named: could-not-judge when nothing readable is declared, named in the verdict otherwise.
|
|
55
|
+
Two readable ceilings that disagree are could-not-judge. `findCeiling` (C-19, C-21) reads the same census.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
- **A target with nothing to read exits 2, not 1** (Theta, task k17dna3w0a5jsq3vezmbwexpmh8gy1nb). A
|
|
59
|
+
missing path used to crash on an uncaught `ENOENT`, which Node exits as 1, the code for "a
|
|
60
|
+
violation". An empty directory was judged, and its absent README and lockfile were reported as
|
|
61
|
+
violations. `loadTree` now throws `SubjectUnreadableError`, and the CLI answers
|
|
62
|
+
`could not judge … (exit 2)` for every form a target can take that yields no readable file:
|
|
63
|
+
- the stat fails: missing, permission, a path through a file;
|
|
64
|
+
- the directory cannot be listed;
|
|
65
|
+
- the directory is empty;
|
|
66
|
+
- the walk reaches no file;
|
|
67
|
+
- the target is a file other than `.ts`/`.tsx`;
|
|
68
|
+
- the target is neither a file nor a directory.
|
|
69
|
+
|
|
70
|
+
A directory holding one file is still judged, and a real violation there still exits 1. Inside
|
|
71
|
+
a readable subject, a subdirectory that cannot be listed, or an entry that cannot be read, is
|
|
72
|
+
now named in `skipped directories` instead of being dropped in silence.
|
|
73
|
+
- **S-22** (Argus, #56): an `allowedOrigins` element bound to a const — an identifier or a spread —
|
|
74
|
+
resolves to the literal it holds, same file first; a wildcard or an empty list behind a const is red.
|
|
75
|
+
A type annotation (`allowedOrigins: readonly string[];`) is no value and no longer reads as an
|
|
76
|
+
unresolved one.
|
|
77
|
+
- **DLocalIdentity** (Eta, vantage-mcp-boilerplate #14): a bare hash over local content — file bytes, a
|
|
78
|
+
directory listing, a digest of them — is a content fingerprint of the tree, not a credential digest.
|
|
79
|
+
Told apart by what the hash consumes, never by a file or function name; a held hash that is updated
|
|
80
|
+
with anything else, or handed elsewhere, stays a site, and so does every keyed primitive.
|
|
81
|
+
- **D3 / D4**: a `content: [` site is attributed to the tool whose extent CONTAINS it (a registration
|
|
82
|
+
call's parentheses, a descriptor object), never to the last tool name seen above it. An unrelated
|
|
83
|
+
`{ name: "list_items" }` row no longer turns a shared content helper into a list builder.
|
|
84
|
+
- **S35** (Eta, #57 REVISE): the narrowing is read when the returned value is not bound by
|
|
85
|
+
`const x = await <call>`. Three forms that fell through to `present` are now judged: the call as an
|
|
86
|
+
argument of `scopeFilterList` / `scopeFilterGet` or the receiver of a `.filter` chain; a `.then(cb)`
|
|
87
|
+
chained on the call (cb's body is read; a callback the scan cannot read is could-not-judge, naming
|
|
88
|
+
the site); one plain rebinding `const y = x`. Narrowing present over an unscoped read is red;
|
|
89
|
+
narrowing absent in that form stays `present`. Declared, not read: a call passed as an argument of
|
|
90
|
+
`.filter(...)` itself, and a second rebinding.
|
|
91
|
+
- **Foreign-material probe** (Eta, #57 REVISE): `BP_MAIN_REF` is pinned to `45f4fbe2` (no longer
|
|
92
|
+
`origin/main`); the missing G6 SHA default is removed and the G6 substrate for `c13` / `d3` is derived
|
|
93
|
+
by overlaying `fixtures/v081-bp-g6-factory/head/src` on the pinned main export (the manifest form of
|
|
94
|
+
the lost G6 head is re-created and declared as constructed); the G6 substrate is built only when
|
|
95
|
+
`c13` or `d3` is requested, so main-only sections run without it; the `s35` section states what it
|
|
96
|
+
checks at the pinned ref.
|
|
97
|
+
|
|
98
|
+
Foreign-material bite proof: `BP_REPO=<vantage-mcp-boilerplate checkout> npm run test:v081-foreign-bite`
|
|
99
|
+
(`scripts/v081-foreign-bite-probe.sh`; the `c11dyn` section builds and launches the boilerplate).
|
|
100
|
+
|
|
101
|
+
### Security
|
|
102
|
+
|
|
103
|
+
- Upgrade dev toolchain (vitest, vite, esbuild...) — no runtime change; published package has no dependencies.
|
|
104
|
+
|
|
105
|
+
|
|
6
106
|
## 0.8.0 — 2026-10-11
|
|
7
107
|
|
|
8
108
|
Measured against the same standard pin as 0.7.0, `b714129a110237c5521d5269ee61ab919408a17f` (91
|
package/README.md
CHANGED
|
@@ -60,7 +60,8 @@ usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both] [--backend=<co
|
|
|
60
60
|
--version print the version and exit
|
|
61
61
|
--help print this and exit
|
|
62
62
|
|
|
63
|
-
exit: 0 clean, 1 a violation, 2 an abstention with no violation
|
|
63
|
+
exit: 0 clean, 1 a violation, 2 an abstention with no violation (including a target
|
|
64
|
+
that names nothing readable: missing, empty, or not a source tree)
|
|
64
65
|
```
|
|
65
66
|
|
|
66
67
|
## States and exit codes
|
|
@@ -84,6 +85,21 @@ The exit code keeps three outcomes distinct, which is what makes the tool safe i
|
|
|
84
85
|
not raise exit 2; the sites stay printed and counted (`owedHumanVerdicts`). Any other `could-not-judge`, with
|
|
85
86
|
or without an `owedVerdict`, still exits 2.
|
|
86
87
|
|
|
88
|
+
**A target with nothing to read exits 2, and no detector runs.** The doctor stops before judging
|
|
89
|
+
anything, prints `could not judge <target> — <what it found>` on stderr, and exits 2. Under `--json`,
|
|
90
|
+
stdout carries `exitCode: 2`, the same text in `subjectUnreadable`, and an empty `results`. This
|
|
91
|
+
covers six cases:
|
|
92
|
+
- the path is missing, or cannot be read;
|
|
93
|
+
- the path is a directory that cannot be listed;
|
|
94
|
+
- the path is an empty directory;
|
|
95
|
+
- the path is a directory where no file can be reached;
|
|
96
|
+
- the path is a file other than `.ts`/`.tsx`;
|
|
97
|
+
- the path is something that is neither a file nor a directory.
|
|
98
|
+
|
|
99
|
+
A directory holding even one file is a subject and is judged, so a missing README or lockfile there is
|
|
100
|
+
still a violation (exit 1). Inside a judged tree, a subdirectory that cannot be listed is named under
|
|
101
|
+
`skipped directories`; it is never dropped in silence.
|
|
102
|
+
|
|
87
103
|
**One known limit in 0.3.2, stated rather than hidden:**
|
|
88
104
|
|
|
89
105
|
- **`absent` currently means two things.** It is used both for "this rule does not apply here" and,
|
|
@@ -128,7 +144,7 @@ Each detector maps one standard item and is proven **bipolar** on real material:
|
|
|
128
144
|
| **D7** protocol-kit | `@vantageos/mcp-protocol-kit` listed in `package.json` **and actually called** (builder invoked, not merely declared) | listed+called / listed-not-called / hand-rolled. Conformant too on `@modelcontextprotocol/server` >= 2 with no hand-rolled result builder (the kit is required only where builders are written by hand) |
|
|
129
145
|
| **D15** cache-coherence | Server Rule 15 — every tool-set mutation (RegisteredTool `.enable/.disable/.update/.remove`, `unregisterTool`) is correlated with a `notifications/tools/list_changed` emission | present / absent / non-conforming / could-not-judge |
|
|
130
146
|
| **D16** version-observable | Server Rule 16 — `server/discover` (or an HTTP `/health` route) exposes a `version` field sourced from `package.json`, reachable pre-auth; an SDK v2 `new McpServer({ name, version })` carrying a `package.json`-derived version counts (pre-auth reach is the dynamic half) | present / absent / non-conforming / could-not-judge |
|
|
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 |
|
|
147
|
+
| **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, or over local content — file bytes, a directory listing, a digest of them, i.e. a content fingerprint of the built tree — 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 |
|
|
132
148
|
| **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) |
|
|
133
149
|
|
|
134
150
|
### Detectors added in 0.7.0 (standard `b714129a`, ElPi-Corp #243)
|
|
@@ -197,6 +213,19 @@ an error message) it counts only as code or as a quoted property key.
|
|
|
197
213
|
| **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
214
|
| **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
215
|
|
|
216
|
+
### Changes in 0.8.1 (standard `6d463a1e` = v2.6 `24c1ffc9` + S-35)
|
|
217
|
+
|
|
218
|
+
| ID | Rule | The static run reads | Verdicts |
|
|
219
|
+
|----|------|----------------------|----------|
|
|
220
|
+
| **S35** scope-in-read | S-35 | each `<client>.query\|action(<door>, <args>)` and, in the same block, a narrowing of what it returned (`scopeFilterList`, `scopeFilterGet`, a `.filter` on an organisation / namespace / recipient / owner field or the caller context, a skipping branch in a loop) | absent / present / non-conforming / could-not-judge (door named by an expression) |
|
|
221
|
+
|
|
222
|
+
**C-11 (b)** dynamic: an invalid cursor is sent to each protocol list method (`-32602`) and as the cursor
|
|
223
|
+
argument of each tool that takes one (`isError: true`, `structuredContent.error.code` = `invalid_cursor`).
|
|
224
|
+
**C-9** accepts `prepare` (a read verb). **C-13** reads the ceiling from the server manifest's
|
|
225
|
+
`contextCeiling` as well as from a source constant. **S-22** resolves an `allowedOrigins` element held by a
|
|
226
|
+
same-file const. **DLocalIdentity** does not count a hash over local file content. **D3 / D4** attribute a
|
|
227
|
+
result builder to the tool that contains it.
|
|
228
|
+
|
|
200
229
|
**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
230
|
and A-5 return `present` on a static positive path: the verdict says `static half: ...` and names the half it
|
|
202
231
|
did NOT read (`NOT read by this run: ...`) — the served output conforms, progress is monotone, the negotiation
|
package/dist/doctor.d.ts
CHANGED
|
@@ -57,6 +57,29 @@ interface Tree {
|
|
|
57
57
|
*/
|
|
58
58
|
unscannedSource?: string[];
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The target names nothing the doctor can read, so there is no subject to judge. Thrown by
|
|
62
|
+
* `loadTree` before any detector runs; the CLI turns it into exit 2 ("could not judge"), never 1:
|
|
63
|
+
* a verdict of "violation" about a tree that was never read is a finding manufactured from nothing.
|
|
64
|
+
*
|
|
65
|
+
* The forms a target can take come from `fs.statSync` and `fs.readdirSync`, and every one of them
|
|
66
|
+
* that yields no readable file ends here, named:
|
|
67
|
+
* - stat fails (does not exist, permission, a path through a file, a loop);
|
|
68
|
+
* - a directory that cannot be listed;
|
|
69
|
+
* - a directory with no entries;
|
|
70
|
+
* - a directory whose walk reaches no readable file (only empty, skipped, unreadable or
|
|
71
|
+
* non-regular entries — a FIFO, socket or device is never read: reading one can block);
|
|
72
|
+
* - a regular file that is not `.ts` / `.tsx` (the only single files the scan reads);
|
|
73
|
+
* - a `.ts` / `.tsx` file that cannot be read;
|
|
74
|
+
* - anything that is neither a directory nor a regular file.
|
|
75
|
+
* A directory holding at least one file IS a subject: the detectors judge it, and a missing README
|
|
76
|
+
* or lockfile there is a real violation.
|
|
77
|
+
*/
|
|
78
|
+
declare class SubjectUnreadableError extends Error {
|
|
79
|
+
readonly target: string;
|
|
80
|
+
readonly form: string;
|
|
81
|
+
constructor(target: string, form: string);
|
|
82
|
+
}
|
|
60
83
|
|
|
61
84
|
type State = "present" | "absent" | "non-conforming" | "could-not-judge";
|
|
62
85
|
/**
|
|
@@ -124,6 +147,39 @@ interface RuntimeTool {
|
|
|
124
147
|
* eval file has `evalCaseCount` unset, which D8 reads as zero coverage.
|
|
125
148
|
*/
|
|
126
149
|
evalCaseCount?: number;
|
|
150
|
+
/**
|
|
151
|
+
* True when the served `inputSchema` declares a `cursor` property: the tool takes a cursor as an
|
|
152
|
+
* ARGUMENT, which C-11 (b) probes with an invalid value (SEP-1303).
|
|
153
|
+
*/
|
|
154
|
+
cursorArgument?: boolean;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* What one invalid-cursor probe read (C-11). The domain is the JSON-RPC response shapes: an `error`
|
|
158
|
+
* member, a `result` member (with or without `isError`, with or without
|
|
159
|
+
* `structuredContent.error.code`), no answer before the deadline, or a probe the introspector
|
|
160
|
+
* refused to send (it never invents a value for another required argument).
|
|
161
|
+
*/
|
|
162
|
+
type CursorProbeOutcome = {
|
|
163
|
+
kind: "error";
|
|
164
|
+
code: unknown;
|
|
165
|
+
message?: string;
|
|
166
|
+
} | {
|
|
167
|
+
kind: "result";
|
|
168
|
+
isError: boolean;
|
|
169
|
+
errorCode?: unknown;
|
|
170
|
+
} | {
|
|
171
|
+
kind: "no-answer";
|
|
172
|
+
reason: string;
|
|
173
|
+
} | {
|
|
174
|
+
kind: "not-sent";
|
|
175
|
+
reason: string;
|
|
176
|
+
};
|
|
177
|
+
/** One invalid-cursor probe: where the cursor arrived (C-11 (a) list method, (b) tool argument). */
|
|
178
|
+
interface CursorProbe {
|
|
179
|
+
/** The protocol list method (`tools/list` ...) or the tool name. */
|
|
180
|
+
target: string;
|
|
181
|
+
surface: "list-method" | "tool-argument";
|
|
182
|
+
outcome: CursorProbeOutcome;
|
|
127
183
|
}
|
|
128
184
|
/**
|
|
129
185
|
* What the RUNNING target server exposes, gathered by launching it and speaking
|
|
@@ -145,6 +201,12 @@ interface RuntimeContext {
|
|
|
145
201
|
root: string;
|
|
146
202
|
tools: RuntimeTool[];
|
|
147
203
|
launchError?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Set when the target's `evals/evals.json` is present but was NOT READ (permission, or not a
|
|
206
|
+
* regular file). Every `evalCaseCount` is then unset because the file was unread, not because it
|
|
207
|
+
* covers nothing: D8 reads it as `could-not-judge`, never as zero coverage.
|
|
208
|
+
*/
|
|
209
|
+
evalsUnreadable?: string;
|
|
148
210
|
/**
|
|
149
211
|
* The `serverInfo` object the running server returned in its `initialize`
|
|
150
212
|
* response — the same non-authenticated discovery handshake a client reads
|
|
@@ -166,6 +228,12 @@ interface RuntimeContext {
|
|
|
166
228
|
* length and asserts that shape rather than assuming it.
|
|
167
229
|
*/
|
|
168
230
|
expectedCommit?: string;
|
|
231
|
+
/**
|
|
232
|
+
* The invalid-cursor round trips (C-11): one per protocol list method the server declares, and one
|
|
233
|
+
* per served tool that takes a cursor argument. Unset when the launch failed, or when the
|
|
234
|
+
* introspector recorded none (C-11 reads that as could-not-judge, never a pass).
|
|
235
|
+
*/
|
|
236
|
+
cursorProbes?: CursorProbe[];
|
|
169
237
|
}
|
|
170
238
|
interface Detector {
|
|
171
239
|
id: string;
|
|
@@ -351,4 +419,5 @@ declare function renderJson(surface: string, results: DetectorResult[], skipped:
|
|
|
351
419
|
*/
|
|
352
420
|
declare function computeExitCode(results: DetectorResult[]): number;
|
|
353
421
|
|
|
354
|
-
export {
|
|
422
|
+
export { SubjectUnreadableError, computeExitCode, countOwedHumanVerdicts, isLiveDetector, renderJson, renderOwedVerdicts, renderSkipped, renderTable, runDoctor, runDoctorAsync, runDoctorReport };
|
|
423
|
+
export type { LiveDetector, RunDoctorOptions, RunMode, Session };
|