@vantageos/mcp-doctor 0.3.2 → 0.5.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 +55 -0
- package/README.md +26 -4
- package/dist/doctor.d.ts +46 -2
- package/dist/doctor.js +1678 -486
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,61 @@
|
|
|
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.5.0 — unreleased
|
|
7
|
+
|
|
8
|
+
### Added — S-28, a tool forwards the verified claim of its caller (standard `bacc54a`)
|
|
9
|
+
- **`S-28 claim-forwarded`** (`src/detectors/s28-claim-forwarded.ts`), the MCP half of the door audit's cause 1: every MCP caller reaches the backend as one service account, so a tool that does not forward the claim it holds leaves the door nothing to confine to. For each `<client>.query|mutation|action("<module>:<fn>", args)` it reads the call's argument text, the helpers it spreads or attaches (resolved in the tree), the refusal that precedes the call in the same handler, and the backend door list. Red: a call to a door that is not an exit and resolves its caller, with no `verifiedOrg` / `verifiedPerson` in it (a `verifiedActor` added by a wrapped client covers only a bearer that resolved an agent), or one forwarded only through an optional helper with no refusal before the call. Green: the claim is forwarded (and refused-without through an optional helper), or the door is service-account-only / `fleet-wide:` (read from the door). Could-not-judge: no backend found, a door the backend does not define, a door named by an expression, a door whose own source shows no caller resolution. Absent: no Convex client imported at all.
|
|
10
|
+
- **New option `--backend=<convex-dir>`** (and `RunDoctorOptions.backend`, `Tree.backendRoot`, `loadTree(root, backendRoot?)`): the backend source whose door list S-28 reads. Default: the nearest `convex/` (holding a `schema.ts`) above the scanned tree.
|
|
11
|
+
- **Pin.** ElPi-Corp `bacc54a53d5f6f9f20e5254908e2b46807902faa` on `main`, `resources/references/mcp-server-standard.md` blob `fe30518`: the merged S-28 text of ElPi-Corp #239. Its merge commit `7d7df89` was left off `main` by the 2026-10-10 history rewrite; `bacc54a` is its rewritten counterpart and carries the same file blob (as do `7d7df89` and the PR head `6f7794e`, the former pin). Later `main` commits amend other rules and leave S-28 unchanged.
|
|
12
|
+
- Tests: `tests/s28-claim-forwarded.test.ts`. Real material: vantage-peers `16f09070ffd49488ad65b12ec2f8026865d4ab5d` (the commit the door audit measured), `tools.ts` + `verifiedActor.ts` and ten `convex/` modules vendored byte-exact in `fixtures/s28-vp-16f0907/`.
|
|
13
|
+
- Version 0.5.0 (0.4.0 shipped DLocalIdentity, PR #52). The registry has one more detector (47); the oracle snapshot gains one `absent` row, no existing row changed.
|
|
14
|
+
|
|
15
|
+
## 0.4.0 — 2026-10-09
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **`DLocalIdentity no-local-identity` — a local identity primitive in an MCP server is a violation.**
|
|
19
|
+
Operator order 2026-10-09: no application, MCP server or starter defines its own primitive for
|
|
20
|
+
resolving a caller to a tenant, asserting membership of an organisation, filtering rows by a
|
|
21
|
+
caller's scope, or validating a master credential; identity lives only in
|
|
22
|
+
`@vantageos/cloud-identity`. The detector reports every site as `file:line` with its category and
|
|
23
|
+
enclosing declaration: local JWT verification (`jwtVerify`, `createRemoteJWKSet`, `jwt.verify`),
|
|
24
|
+
raw claim reads (`payload.org_id`), a populated `fromAllowList` attached locally, role comparisons,
|
|
25
|
+
name allow-lists (`fromAllowList.includes`, `roster.includes`, `isInAllowList`, iteration over a
|
|
26
|
+
roster), tenant row filters (`row.orgId === caller.orgSlug`), and local credential checks
|
|
27
|
+
(`timingSafeEqual`, `createHash("sha256")`, `createHmac`, `subtle.digest("SHA-256")`). A call
|
|
28
|
+
whose callee is bound from `@vantageos/cloud-identity` is not a site.
|
|
29
|
+
- **EXEMPT, reported:** the `@vantageos/cloud-identity` package itself, and a site justified by
|
|
30
|
+
`// allow-local-identity: <reason ≥ 8 chars>`. **COULD-NOT-JUDGE:** a file that is not UTF-8
|
|
31
|
+
text or whose brackets never balance.
|
|
32
|
+
- **`--local-identity-ceiling=N`** (default 0). One site over the ceiling is `non-conforming`
|
|
33
|
+
(exit 1); at or under it the row reads `present` and still lists every site.
|
|
34
|
+
- Name and tenant compares are caught in every spelling: `===`, `!==`, `==`, `!=`, `Object.is`,
|
|
35
|
+
bracket reads, and case-folded or normalised operands (`toLowerCase()`, `normalize()`). Acting-name
|
|
36
|
+
fields (`createdBy`, `assignedTo`, `from`, `recipient`, `callerOrchestrator`, `orchestrator`, ...) are
|
|
37
|
+
compared to each other or to a caller name; an operand that is `undefined`, `null`, a boolean or a
|
|
38
|
+
number is a presence test and is not a site.
|
|
39
|
+
- A digest over static content is not a credential digest: `subtle.digest` / `createHash().update`
|
|
40
|
+
over a string literal or a module-level constant made of literals (the CSP hash of a static
|
|
41
|
+
stylesheet, `authorize.ts:373`) is not reported. A keyed `createHmac`, and any digest of a value the
|
|
42
|
+
caller supplied, stay sites.
|
|
43
|
+
- Measured on vantage-peers `mcp-server/src` at `8fb7c12`: 33 sites, exit 1.
|
|
44
|
+
- **DLocalIdentity, third REVISE (PR #52):** reported now are `claimedName` (and any
|
|
45
|
+
`caller*`/`actor*`/`acting*`/`claimed*` property) compared to an agent's `.name`;
|
|
46
|
+
`switch (row.createdBy) { case caller: }`; `[caller].includes(row.createdBy)` and
|
|
47
|
+
`new Set([caller]).has(...)`; `a.localeCompare(b) === 0` on names or tenants; a primitive
|
|
48
|
+
imported or destructured under an alias (`import { createHash as h }`). Implemented as regex
|
|
49
|
+
matchers, not an AST: `typescript` is a devDependency only and the report contract is line-based.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
- The registry has one more detector (46). `tests/__oracle__/oracle-complete-server.static.json`
|
|
53
|
+
gains one `present` row; no existing row changed.
|
|
54
|
+
- **DLocalIdentity exemption marker renamed** `local-identity-exempt:` -> `allow-local-identity:`
|
|
55
|
+
(standard R-57), reason of 8 characters or more (was 10). The old spelling no longer exempts.
|
|
56
|
+
- Fix-forward (0.5.0): the third-REVISE items above and the marker rename were first logged under
|
|
57
|
+
`Unreleased`, but shipped in 0.4.0 (npm `@vantageos/mcp-doctor@0.4.0`, gitHead `16a7531`); moved here.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
6
61
|
## 0.3.2 — 2026-10-04
|
|
7
62
|
|
|
8
63
|
### Changed — read this if your CI distinguishes exit codes
|
package/README.md
CHANGED
|
@@ -46,11 +46,16 @@ 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]
|
|
49
|
+
usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both] [--backend=<convex-dir>]
|
|
50
50
|
|
|
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)
|
|
57
|
+
--backend=DIR the backend source (a convex/ dir) whose door list S-28 reads;
|
|
58
|
+
default: the nearest convex/ above the tree
|
|
54
59
|
--version print the version and exit
|
|
55
60
|
--help print this and exit
|
|
56
61
|
|
|
@@ -90,12 +95,18 @@ green on 0.2.0 was green because nothing ran. Expect findings on the first run o
|
|
|
90
95
|
## Programmatic use
|
|
91
96
|
|
|
92
97
|
```ts
|
|
93
|
-
import {
|
|
98
|
+
import { runDoctorReport, renderTable } from "@vantageos/mcp-doctor";
|
|
94
99
|
|
|
95
|
-
const results =
|
|
96
|
-
console.log(renderTable("./mcp-server", results));
|
|
100
|
+
const { results, skipped } = runDoctorReport("./mcp-server");
|
|
101
|
+
console.log(renderTable("./mcp-server", results, skipped));
|
|
97
102
|
```
|
|
98
103
|
|
|
104
|
+
`skipped` is the list of directories the run actually walked past (`node_modules`, `dist`, test
|
|
105
|
+
material...), recorded by the walks as they skip them. `renderTable` prints it as the
|
|
106
|
+
"skipped directories" section, so a report never reads as full coverage of a narrowed scope. Pass it
|
|
107
|
+
through; called with two arguments, `renderTable` prints a "NOT REPORTED" line in its place.
|
|
108
|
+
`runDoctor(target)` still returns the results alone.
|
|
109
|
+
|
|
99
110
|
## What it checks (MCP 2026-07-28)
|
|
100
111
|
|
|
101
112
|
Each detector maps one standard item and is proven **bipolar** on real material: a genuine defect
|
|
@@ -111,6 +122,17 @@ Each detector maps one standard item and is proven **bipolar** on real material:
|
|
|
111
122
|
| **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
123
|
| **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
124
|
| **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 |
|
|
125
|
+
| **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 |
|
|
126
|
+
| **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
|
+
|
|
128
|
+
**DLocalIdentity exemptions.** Two, both reported as `EXEMPT` rather than hidden: the
|
|
129
|
+
`@vantageos/cloud-identity` package itself (read from the target's `package.json` name), and a site
|
|
130
|
+
carrying `// allow-local-identity: <reason of 8 characters or more>` on the line, in the comment
|
|
131
|
+
block directly above it, or above its enclosing top-level declaration. A marker with a shorter reason,
|
|
132
|
+
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
|
|
133
|
+
not UTF-8 text, is `could-not-judge`. To adopt the rule on a tree that still holds sites, pass
|
|
134
|
+
`--local-identity-ceiling=<current count>` as a ratchet; the row then reads `present` and still lists
|
|
135
|
+
every site.
|
|
114
136
|
|
|
115
137
|
**This table is a sample, not the inventory.** Every registered detector runs on every invocation,
|
|
116
138
|
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,20 @@ 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[];
|
|
46
|
+
/**
|
|
47
|
+
* The backend source root (a `convex/` directory) a detector may read the
|
|
48
|
+
* backend's door list from. Set by the caller (`--backend=<dir>`); when unset a
|
|
49
|
+
* detector that needs it looks for the nearest `convex/` above `root` and says
|
|
50
|
+
* so when it finds none. Unset means "not supplied", never "no backend".
|
|
51
|
+
*/
|
|
52
|
+
backendRoot?: string;
|
|
26
53
|
}
|
|
27
54
|
|
|
28
55
|
type State = "present" | "absent" | "non-conforming" | "could-not-judge";
|
|
@@ -197,6 +224,8 @@ declare function isLiveDetector(d: Detector | LiveDetector): d is LiveDetector;
|
|
|
197
224
|
type RunMode = "static" | "dynamic" | "both";
|
|
198
225
|
interface RunDoctorOptions {
|
|
199
226
|
mode?: RunMode;
|
|
227
|
+
/** The backend source root (`convex/`) a detector reads the backend's door list from (S-28). */
|
|
228
|
+
backend?: string;
|
|
200
229
|
}
|
|
201
230
|
/**
|
|
202
231
|
* Run every detector against the target.
|
|
@@ -218,6 +247,15 @@ interface RunDoctorOptions {
|
|
|
218
247
|
* `detectorList` is injectable for tests; production uses the full registry.
|
|
219
248
|
*/
|
|
220
249
|
declare function runDoctor(target: string, detectorList?: Detector[], opts?: RunDoctorOptions): DetectorResult[];
|
|
250
|
+
/**
|
|
251
|
+
* `runDoctor` plus the directories the run ACTUALLY skipped, read from the tree
|
|
252
|
+
* AFTER the detectors ran (a detector that walks the tree records its own skips
|
|
253
|
+
* into it). This is what the report's skipped-directories section prints.
|
|
254
|
+
*/
|
|
255
|
+
declare function runDoctorReport(target: string, detectorList?: Detector[], opts?: RunDoctorOptions): {
|
|
256
|
+
results: DetectorResult[];
|
|
257
|
+
skipped: SkippedDir[];
|
|
258
|
+
};
|
|
221
259
|
/**
|
|
222
260
|
* Async runner — the ADDITIVE extension for *dynamique* rules that must CALL a
|
|
223
261
|
* running server. It is a superset of the sync `runDoctor` static path, never a
|
|
@@ -240,7 +278,13 @@ declare function runDoctorAsync(target: string, opts?: {
|
|
|
240
278
|
session?: Session;
|
|
241
279
|
detectorList?: (Detector | LiveDetector)[];
|
|
242
280
|
}): Promise<DetectorResult[]>;
|
|
243
|
-
|
|
281
|
+
/**
|
|
282
|
+
* The skipped-directories section. A skipped directory narrows the scope of the
|
|
283
|
+
* run, and a report that does not say so reads as full coverage. Always
|
|
284
|
+
* rendered: an empty list says "none", it is never omitted.
|
|
285
|
+
*/
|
|
286
|
+
declare function renderSkipped(skipped: SkippedDir[]): string;
|
|
287
|
+
declare function renderTable(surface: string, results: DetectorResult[], skipped?: SkippedDir[]): string;
|
|
244
288
|
/**
|
|
245
289
|
* The process exit code a run's results carry, three-way with precedence:
|
|
246
290
|
* 1 — a VIOLATION: at least one detector is `non-conforming` (something IS
|
|
@@ -253,4 +297,4 @@ declare function renderTable(surface: string, results: DetectorResult[]): string
|
|
|
253
297
|
*/
|
|
254
298
|
declare function computeExitCode(results: DetectorResult[]): number;
|
|
255
299
|
|
|
256
|
-
export { type LiveDetector, type RunDoctorOptions, type RunMode, type Session, computeExitCode, isLiveDetector, renderTable, runDoctor, runDoctorAsync };
|
|
300
|
+
export { type LiveDetector, type RunDoctorOptions, type RunMode, type Session, computeExitCode, isLiveDetector, renderSkipped, renderTable, runDoctor, runDoctorAsync, runDoctorReport };
|