@vantageos/mcp-doctor 0.3.1 → 0.4.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 +81 -0
- package/README.md +25 -10
- package/dist/doctor.d.ts +43 -2
- package/dist/doctor.js +2048 -984
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,87 @@
|
|
|
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
|
+
## Unreleased
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- **DLocalIdentity exemption marker renamed** `local-identity-exempt:` -> `allow-local-identity:`
|
|
10
|
+
(standard R-57), reason of 8 characters or more (was 10). The old spelling no longer exempts.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **DLocalIdentity, third REVISE (PR #52):** reported now are `claimedName` (and any
|
|
14
|
+
`caller*`/`actor*`/`acting*`/`claimed*` property) compared to an agent's `.name`;
|
|
15
|
+
`switch (row.createdBy) { case caller: }`; `[caller].includes(row.createdBy)` and
|
|
16
|
+
`new Set([caller]).has(...)`; `a.localeCompare(b) === 0` on names or tenants; a primitive
|
|
17
|
+
imported or destructured under an alias (`import { createHash as h }`). Implemented as regex
|
|
18
|
+
matchers, not an AST: `typescript` is a devDependency only and the report contract is line-based.
|
|
19
|
+
|
|
20
|
+
## 0.4.0 — 2026-10-09
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **`DLocalIdentity no-local-identity` — a local identity primitive in an MCP server is a violation.**
|
|
24
|
+
Operator order 2026-10-09: no application, MCP server or starter defines its own primitive for
|
|
25
|
+
resolving a caller to a tenant, asserting membership of an organisation, filtering rows by a
|
|
26
|
+
caller's scope, or validating a master credential; identity lives only in
|
|
27
|
+
`@vantageos/cloud-identity`. The detector reports every site as `file:line` with its category and
|
|
28
|
+
enclosing declaration: local JWT verification (`jwtVerify`, `createRemoteJWKSet`, `jwt.verify`),
|
|
29
|
+
raw claim reads (`payload.org_id`), a populated `fromAllowList` attached locally, role comparisons,
|
|
30
|
+
name allow-lists (`fromAllowList.includes`, `roster.includes`, `isInAllowList`, iteration over a
|
|
31
|
+
roster), tenant row filters (`row.orgId === caller.orgSlug`), and local credential checks
|
|
32
|
+
(`timingSafeEqual`, `createHash("sha256")`, `createHmac`, `subtle.digest("SHA-256")`). A call
|
|
33
|
+
whose callee is bound from `@vantageos/cloud-identity` is not a site.
|
|
34
|
+
- **EXEMPT, reported:** the `@vantageos/cloud-identity` package itself, and a site justified by
|
|
35
|
+
`// allow-local-identity: <reason ≥ 8 chars>`. **COULD-NOT-JUDGE:** a file that is not UTF-8
|
|
36
|
+
text or whose brackets never balance.
|
|
37
|
+
- **`--local-identity-ceiling=N`** (default 0). One site over the ceiling is `non-conforming`
|
|
38
|
+
(exit 1); at or under it the row reads `present` and still lists every site.
|
|
39
|
+
- Name and tenant compares are caught in every spelling: `===`, `!==`, `==`, `!=`, `Object.is`,
|
|
40
|
+
bracket reads, and case-folded or normalised operands (`toLowerCase()`, `normalize()`). Acting-name
|
|
41
|
+
fields (`createdBy`, `assignedTo`, `from`, `recipient`, `callerOrchestrator`, `orchestrator`, ...) are
|
|
42
|
+
compared to each other or to a caller name; an operand that is `undefined`, `null`, a boolean or a
|
|
43
|
+
number is a presence test and is not a site.
|
|
44
|
+
- A digest over static content is not a credential digest: `subtle.digest` / `createHash().update`
|
|
45
|
+
over a string literal or a module-level constant made of literals (the CSP hash of a static
|
|
46
|
+
stylesheet, `authorize.ts:373`) is not reported. A keyed `createHmac`, and any digest of a value the
|
|
47
|
+
caller supplied, stay sites.
|
|
48
|
+
- Measured on vantage-peers `mcp-server/src` at `8fb7c12`: 33 sites, exit 1.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
- The registry has one more detector (46). `tests/__oracle__/oracle-complete-server.static.json`
|
|
52
|
+
gains one `present` row; no existing row changed.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 0.3.2 — 2026-10-04
|
|
57
|
+
|
|
58
|
+
### Changed — read this if your CI distinguishes exit codes
|
|
59
|
+
- **Unreadable input is now `could-not-judge` (exit 2), no longer a crash (exit 1).**
|
|
60
|
+
A tree holding input a detector could not read — malformed JSON in an extension
|
|
61
|
+
manifest or `evals.json`, a computed or non-literal value where a literal is
|
|
62
|
+
required — used to abort the run with a stack trace and exit 1, the code this
|
|
63
|
+
tool uses for *a violation*. It now completes the run, names the unreadable
|
|
64
|
+
file in the row, and exits 2 (no violation, but part of the tree was not judged).
|
|
65
|
+
Pipelines that treat any non-zero exit as failure are unaffected. Pipelines that
|
|
66
|
+
distinguish 1 from 2 must read the new state: a run that used to exit 1 on
|
|
67
|
+
unreadable input now exits 2. (#46)
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
- **A manifest that parses but is wrong is `non-conforming`.** It is a finding
|
|
71
|
+
(exit 1) rather than an abstention or a crash. (#46)
|
|
72
|
+
- **An unparseable `package.json` is `could-not-judge` for C17, D10 and D7** (and C14, D12), where each had judged an empty manifest and reported the emptiness as a finding (C17, D10 non-conforming) or an absence (D7), so the run exited 1 instead of 2. The parse failure is now recorded once at load time and every detector reading that file honours it.
|
|
73
|
+
- **Tools declared as descriptor objects are now found.** Five detectors (D3, D4, D8, D11, DServer2) and C19 reported "no registered tools" on servers that declare their tools as descriptor objects (`{ name, inputSchema }`). They now find those tools. A tree that serves tools but where none can be located reads `could-not-judge`, not `absent`. On a real vCRM tree, the "no registered tools found" rows went from 1 to 0, and D11 is now a real finding. (#48)
|
|
74
|
+
- **`package.json` is now what npm publishes.** `npm publish` of 0.3.1 printed
|
|
75
|
+
"npm auto-corrected some errors in your package.json": `bin` was `"./dist/doctor.js"`
|
|
76
|
+
and is now `"dist/doctor.js"`, and `files` is reformatted, both exactly as
|
|
77
|
+
`npm pkg fix` writes them. A new test, `tests/publish-manifest-clean.test.ts`,
|
|
78
|
+
runs `npm publish --dry-run` and fails if that warning returns, and asserts the
|
|
79
|
+
tarball lists `dist/doctor.js`, `README.md` and `LICENSE`.
|
|
80
|
+
|
|
81
|
+
### Docs
|
|
82
|
+
- README: the states table and the known limits no longer say unreadable input is
|
|
83
|
+
handled in band by D9 only.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
6
87
|
## 0.3.1 — 2026-10-04
|
|
7
88
|
|
|
8
89
|
### Fixed
|
package/README.md
CHANGED
|
@@ -51,6 +51,9 @@ usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both]
|
|
|
51
51
|
--mode=static read the tree only (default)
|
|
52
52
|
--mode=dynamic launch the server and judge what it serves
|
|
53
53
|
--mode=both both, dynamic findings taking precedence
|
|
54
|
+
--local-identity-ceiling=N
|
|
55
|
+
local identity primitives tolerated before DLocalIdentity
|
|
56
|
+
fails (default 0)
|
|
54
57
|
--version print the version and exit
|
|
55
58
|
--help print this and exit
|
|
56
59
|
|
|
@@ -63,8 +66,8 @@ exit: 0 clean, 1 a violation, 2 an abstention with no violation
|
|
|
63
66
|
|---|---|---|
|
|
64
67
|
| `present` | the standard item is satisfied | none |
|
|
65
68
|
| `absent` | the detector found nothing to judge — see the caveat below | none |
|
|
66
|
-
| `non-conforming` | the item is present and wrong | **exit 1** |
|
|
67
|
-
| `could-not-judge` | the detector could not read its input and says so in the row —
|
|
69
|
+
| `non-conforming` | the item is present and wrong (this includes a manifest that parses but holds a wrong value) | **exit 1** |
|
|
70
|
+
| `could-not-judge` | the detector could not read its input and says so in the row — malformed JSON, a computed or non-literal value, no `package.json` above the root; the run completes and the row names the file | **exit 2**, unless something else is non-conforming |
|
|
68
71
|
|
|
69
72
|
The exit code keeps three outcomes distinct, which is what makes the tool safe in CI:
|
|
70
73
|
|
|
@@ -73,16 +76,12 @@ The exit code keeps three outcomes distinct, which is what makes the tool safe i
|
|
|
73
76
|
- **2** — no violation, but at least one detector abstained. **An abstention is never a pass**: it
|
|
74
77
|
means part of the tree was not judged, and the row names which part and why.
|
|
75
78
|
|
|
76
|
-
**
|
|
79
|
+
**One known limit in 0.3.2, stated rather than hidden:**
|
|
77
80
|
|
|
78
81
|
- **`absent` currently means two things.** It is used both for "this rule does not apply here" and,
|
|
79
82
|
for some detectors, "this is required and missing". The second should move the exit code and
|
|
80
83
|
today does not. Until that is split, read an `absent` row against the rule it names rather than
|
|
81
84
|
treating it as harmless.
|
|
82
|
-
- **Unreadable input is handled in band by D9 only.** In 0.3.1, D9 reports `could-not-judge` when no
|
|
83
|
-
`package.json` sits above the scanned root, and the run completes. Several other detectors still
|
|
84
|
-
**throw** on unreadable input — a malformed manifest, a computed value — which aborts the run with
|
|
85
|
-
exit 1 and a stack trace, indistinguishable from a violation. Converting them is in progress.
|
|
86
85
|
|
|
87
86
|
By design, a detector whose own internal self-check fails stops the run — that is the instrument
|
|
88
87
|
reporting that it is broken, and silencing it would hide the fault behind a normal-looking table.
|
|
@@ -94,12 +93,18 @@ green on 0.2.0 was green because nothing ran. Expect findings on the first run o
|
|
|
94
93
|
## Programmatic use
|
|
95
94
|
|
|
96
95
|
```ts
|
|
97
|
-
import {
|
|
96
|
+
import { runDoctorReport, renderTable } from "@vantageos/mcp-doctor";
|
|
98
97
|
|
|
99
|
-
const results =
|
|
100
|
-
console.log(renderTable("./mcp-server", results));
|
|
98
|
+
const { results, skipped } = runDoctorReport("./mcp-server");
|
|
99
|
+
console.log(renderTable("./mcp-server", results, skipped));
|
|
101
100
|
```
|
|
102
101
|
|
|
102
|
+
`skipped` is the list of directories the run actually walked past (`node_modules`, `dist`, test
|
|
103
|
+
material...), recorded by the walks as they skip them. `renderTable` prints it as the
|
|
104
|
+
"skipped directories" section, so a report never reads as full coverage of a narrowed scope. Pass it
|
|
105
|
+
through; called with two arguments, `renderTable` prints a "NOT REPORTED" line in its place.
|
|
106
|
+
`runDoctor(target)` still returns the results alone.
|
|
107
|
+
|
|
103
108
|
## What it checks (MCP 2026-07-28)
|
|
104
109
|
|
|
105
110
|
Each detector maps one standard item and is proven **bipolar** on real material: a genuine defect
|
|
@@ -115,6 +120,16 @@ Each detector maps one standard item and is proven **bipolar** on real material:
|
|
|
115
120
|
| **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 |
|
|
116
121
|
| **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 |
|
|
117
122
|
| **D16** version-observable | Server Rule 16 — `server/discover` (or an HTTP `/health` route) exposes a `version` field sourced from `package.json`, reachable pre-auth | present / absent / non-conforming / could-not-judge |
|
|
123
|
+
| **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. | present (0, or EXEMPT) / non-conforming (count > ceiling, default 0) / could-not-judge |
|
|
124
|
+
|
|
125
|
+
**DLocalIdentity exemptions.** Two, both reported as `EXEMPT` rather than hidden: the
|
|
126
|
+
`@vantageos/cloud-identity` package itself (read from the target's `package.json` name), and a site
|
|
127
|
+
carrying `// allow-local-identity: <reason of 8 characters or more>` on the line, in the comment
|
|
128
|
+
block directly above it, or above its enclosing top-level declaration. A marker with a shorter reason,
|
|
129
|
+
or the retired `local-identity-exempt:` spelling, is not a justification and the site stays a violation. A file whose brackets never balance, or that is
|
|
130
|
+
not UTF-8 text, is `could-not-judge`. To adopt the rule on a tree that still holds sites, pass
|
|
131
|
+
`--local-identity-ceiling=<current count>` as a ratchet; the row then reads `present` and still lists
|
|
132
|
+
every site.
|
|
118
133
|
|
|
119
134
|
**This table is a sample, not the inventory.** Every registered detector runs on every invocation,
|
|
120
135
|
so the authoritative list is simply the output of a run — one row per detector. The registered set
|
package/dist/doctor.d.ts
CHANGED
|
@@ -10,13 +10,39 @@ interface SourceFile {
|
|
|
10
10
|
rel: string;
|
|
11
11
|
lines: SourceLine[];
|
|
12
12
|
}
|
|
13
|
+
/** A directory a walk declined to descend into, recorded AS the walk skipped it. */
|
|
14
|
+
interface SkippedDir {
|
|
15
|
+
/** Path relative to the walk root, `/`-separated. */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Why the canonical list excludes this directory name. */
|
|
18
|
+
reason: string;
|
|
19
|
+
/**
|
|
20
|
+
* Set only by a walk that probed the skipped directory for its own candidate
|
|
21
|
+
* inputs (C-26: a deploy-config file). Names the first candidate found there,
|
|
22
|
+
* relative to the walk root. Unset means "not probed", never "nothing there".
|
|
23
|
+
*/
|
|
24
|
+
candidate?: string;
|
|
25
|
+
}
|
|
13
26
|
interface Tree {
|
|
14
27
|
root: string;
|
|
15
28
|
files: SourceFile[];
|
|
29
|
+
/**
|
|
30
|
+
* `unreadable` is set (with the parse error) when the file exists but is not a
|
|
31
|
+
* JSON object; `json` is then `{}` and MUST NOT be read as the file's content.
|
|
32
|
+
* Detectors that read this file go through `guardUnreadablePackageJson`.
|
|
33
|
+
*/
|
|
16
34
|
packageJson: {
|
|
17
35
|
path: string;
|
|
18
36
|
json: Record<string, unknown>;
|
|
37
|
+
unreadable?: string;
|
|
19
38
|
} | null;
|
|
39
|
+
/**
|
|
40
|
+
* Directories skipped on THIS run, recorded by the walks as they skip them
|
|
41
|
+
* (deduplicated by path). Mutable on purpose: a detector that walks the tree
|
|
42
|
+
* itself records into it through `recordSkip`. Never derived from the
|
|
43
|
+
* configured list: the report states what was walked past, not what could be.
|
|
44
|
+
*/
|
|
45
|
+
skippedDirs: SkippedDir[];
|
|
20
46
|
}
|
|
21
47
|
|
|
22
48
|
type State = "present" | "absent" | "non-conforming" | "could-not-judge";
|
|
@@ -212,6 +238,15 @@ interface RunDoctorOptions {
|
|
|
212
238
|
* `detectorList` is injectable for tests; production uses the full registry.
|
|
213
239
|
*/
|
|
214
240
|
declare function runDoctor(target: string, detectorList?: Detector[], opts?: RunDoctorOptions): DetectorResult[];
|
|
241
|
+
/**
|
|
242
|
+
* `runDoctor` plus the directories the run ACTUALLY skipped, read from the tree
|
|
243
|
+
* AFTER the detectors ran (a detector that walks the tree records its own skips
|
|
244
|
+
* into it). This is what the report's skipped-directories section prints.
|
|
245
|
+
*/
|
|
246
|
+
declare function runDoctorReport(target: string, detectorList?: Detector[], opts?: RunDoctorOptions): {
|
|
247
|
+
results: DetectorResult[];
|
|
248
|
+
skipped: SkippedDir[];
|
|
249
|
+
};
|
|
215
250
|
/**
|
|
216
251
|
* Async runner — the ADDITIVE extension for *dynamique* rules that must CALL a
|
|
217
252
|
* running server. It is a superset of the sync `runDoctor` static path, never a
|
|
@@ -234,7 +269,13 @@ declare function runDoctorAsync(target: string, opts?: {
|
|
|
234
269
|
session?: Session;
|
|
235
270
|
detectorList?: (Detector | LiveDetector)[];
|
|
236
271
|
}): Promise<DetectorResult[]>;
|
|
237
|
-
|
|
272
|
+
/**
|
|
273
|
+
* The skipped-directories section. A skipped directory narrows the scope of the
|
|
274
|
+
* run, and a report that does not say so reads as full coverage. Always
|
|
275
|
+
* rendered: an empty list says "none", it is never omitted.
|
|
276
|
+
*/
|
|
277
|
+
declare function renderSkipped(skipped: SkippedDir[]): string;
|
|
278
|
+
declare function renderTable(surface: string, results: DetectorResult[], skipped?: SkippedDir[]): string;
|
|
238
279
|
/**
|
|
239
280
|
* The process exit code a run's results carry, three-way with precedence:
|
|
240
281
|
* 1 — a VIOLATION: at least one detector is `non-conforming` (something IS
|
|
@@ -247,4 +288,4 @@ declare function renderTable(surface: string, results: DetectorResult[]): string
|
|
|
247
288
|
*/
|
|
248
289
|
declare function computeExitCode(results: DetectorResult[]): number;
|
|
249
290
|
|
|
250
|
-
export { type LiveDetector, type RunDoctorOptions, type RunMode, type Session, computeExitCode, isLiveDetector, renderTable, runDoctor, runDoctorAsync };
|
|
291
|
+
export { type LiveDetector, type RunDoctorOptions, type RunMode, type Session, computeExitCode, isLiveDetector, renderSkipped, renderTable, runDoctor, runDoctorAsync, runDoctorReport };
|