@vantageos/mcp-doctor 0.3.2 → 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 CHANGED
@@ -3,6 +3,56 @@
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
+
6
56
  ## 0.3.2 — 2026-10-04
7
57
 
8
58
  ### Changed — read this if your CI distinguishes exit codes
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
 
@@ -90,12 +93,18 @@ green on 0.2.0 was green because nothing ran. Expect findings on the first run o
90
93
  ## Programmatic use
91
94
 
92
95
  ```ts
93
- import { runDoctor, renderTable } from "@vantageos/mcp-doctor";
96
+ import { runDoctorReport, renderTable } from "@vantageos/mcp-doctor";
94
97
 
95
- const results = runDoctor("./mcp-server");
96
- console.log(renderTable("./mcp-server", results));
98
+ const { results, skipped } = runDoctorReport("./mcp-server");
99
+ console.log(renderTable("./mcp-server", results, skipped));
97
100
  ```
98
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
+
99
108
  ## What it checks (MCP 2026-07-28)
100
109
 
101
110
  Each detector maps one standard item and is proven **bipolar** on real material: a genuine defect
@@ -111,6 +120,16 @@ Each detector maps one standard item and is proven **bipolar** on real material:
111
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 |
112
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 |
113
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.
114
133
 
115
134
  **This table is a sample, not the inventory.** Every registered detector runs on every invocation,
116
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,6 +10,19 @@ 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[];
@@ -23,6 +36,13 @@ interface Tree {
23
36
  json: Record<string, unknown>;
24
37
  unreadable?: string;
25
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[];
26
46
  }
27
47
 
28
48
  type State = "present" | "absent" | "non-conforming" | "could-not-judge";
@@ -218,6 +238,15 @@ interface RunDoctorOptions {
218
238
  * `detectorList` is injectable for tests; production uses the full registry.
219
239
  */
220
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
+ };
221
250
  /**
222
251
  * Async runner — the ADDITIVE extension for *dynamique* rules that must CALL a
223
252
  * running server. It is a superset of the sync `runDoctor` static path, never a
@@ -240,7 +269,13 @@ declare function runDoctorAsync(target: string, opts?: {
240
269
  session?: Session;
241
270
  detectorList?: (Detector | LiveDetector)[];
242
271
  }): Promise<DetectorResult[]>;
243
- declare function renderTable(surface: string, results: DetectorResult[]): string;
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;
244
279
  /**
245
280
  * The process exit code a run's results carry, three-way with precedence:
246
281
  * 1 — a VIOLATION: at least one detector is `non-conforming` (something IS
@@ -253,4 +288,4 @@ declare function renderTable(surface: string, results: DetectorResult[]): string
253
288
  */
254
289
  declare function computeExitCode(results: DetectorResult[]): number;
255
290
 
256
- 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 };