@vantageos/mcp-doctor 0.4.0 → 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 CHANGED
@@ -3,19 +3,14 @@
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
6
+ ## 0.5.0 — unreleased
7
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.
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.
19
14
 
20
15
  ## 0.4.0 — 2026-10-09
21
16
 
@@ -46,10 +41,20 @@ This project adheres to [Semantic Versioning](https://semver.org/).
46
41
  stylesheet, `authorize.ts:373`) is not reported. A keyed `createHmac`, and any digest of a value the
47
42
  caller supplied, stay sites.
48
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.
49
50
 
50
51
  ### Changed
51
52
  - The registry has one more detector (46). `tests/__oracle__/oracle-complete-server.static.json`
52
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.
53
58
 
54
59
  ---
55
60
 
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]
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
@@ -54,6 +54,8 @@ usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both]
54
54
  --local-identity-ceiling=N
55
55
  local identity primitives tolerated before DLocalIdentity
56
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
57
59
  --version print the version and exit
58
60
  --help print this and exit
59
61
 
@@ -121,6 +123,7 @@ Each detector maps one standard item and is proven **bipolar** on real material:
121
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 |
122
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 |
123
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) |
124
127
 
125
128
  **DLocalIdentity exemptions.** Two, both reported as `EXEMPT` rather than hidden: the
126
129
  `@vantageos/cloud-identity` package itself (read from the target's `package.json` name), and a site
package/dist/doctor.d.ts CHANGED
@@ -43,6 +43,13 @@ interface Tree {
43
43
  * configured list: the report states what was walked past, not what could be.
44
44
  */
45
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;
46
53
  }
47
54
 
48
55
  type State = "present" | "absent" | "non-conforming" | "could-not-judge";
@@ -217,6 +224,8 @@ declare function isLiveDetector(d: Detector | LiveDetector): d is LiveDetector;
217
224
  type RunMode = "static" | "dynamic" | "both";
218
225
  interface RunDoctorOptions {
219
226
  mode?: RunMode;
227
+ /** The backend source root (`convex/`) a detector reads the backend's door list from (S-28). */
228
+ backend?: string;
220
229
  }
221
230
  /**
222
231
  * Run every detector against the target.
package/dist/doctor.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/doctor.ts
4
- import { readFileSync as readFileSync12, realpathSync } from "fs";
5
- import { dirname as dirname11, join as join19, resolve as resolve2 } from "path";
4
+ import { readFileSync as readFileSync13, realpathSync } from "fs";
5
+ import { dirname as dirname12, join as join20, resolve as resolve2 } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
 
8
8
  // src/scan.ts
@@ -114,11 +114,12 @@ function findPackageJson(startDir) {
114
114
  }
115
115
  return null;
116
116
  }
117
- function loadTree(root) {
117
+ function loadTree(root, backendRoot) {
118
118
  const st = statSync(root);
119
119
  const baseDir = st.isDirectory() ? root : dirname(root);
120
120
  const filePaths = [];
121
121
  const tree = { root, files: [], packageJson: null, skippedDirs: [] };
122
+ if (backendRoot !== void 0) tree.backendRoot = backendRoot;
122
123
  if (st.isDirectory()) walk(root, filePaths, (d) => recordSkip(tree, d, root));
123
124
  else if (root.endsWith(".ts") || root.endsWith(".tsx")) filePaths.push(root);
124
125
  tree.files = filePaths.map((p) => loadFile(p, baseDir));
@@ -7215,6 +7216,436 @@ var dServer14 = {
7215
7216
  }
7216
7217
  };
7217
7218
 
7219
+ // src/detectors/s28-claim-forwarded.ts
7220
+ import { existsSync as existsSync15, readFileSync as readFileSync11, readdirSync as readdirSync7, statSync as statSync8 } from "fs";
7221
+ import { dirname as dirname10, join as join18, relative as relative4 } from "path";
7222
+ var PIN11 = "bacc54a53d5f6f9f20e5254908e2b46807902faa";
7223
+ var CLAIM_TOKEN = /\bverified(?:Org|Person)\b/;
7224
+ var ANY_CLAIM = /\bverified(?:Actor|Org|Person)\b/g;
7225
+ var CALL_RE = /\b([A-Za-z_$][\w$]*)\s*\.\s*(query|mutation|action)\s*\(/g;
7226
+ var GUARD_RE = /\b(?:unresolved\w*Denial|requireVerified\w+|requireClaim\w*)\s*\(|verified-org-unresolved/;
7227
+ var FLEET_WIDE = /fleet-wide:\s*\S[^\n]{7,}/;
7228
+ var SCOPE_MARKERS = /\bwithOrgScope\b|\brequireAuthenticatedCaller\b|\bresolveInboxReader\b|\bresolveVerified\w*\b|\brequireScope\b|\bgetUserIdentity\b|\bisMaster\b|\bscope\b/;
7229
+ var ACTOR_SET_RE = /\bconst\s+(VERIFIED_\w*DOORS)\b[^=]*=\s*new\s+Set\s*(?:<[^>]*>)?\s*\(\s*\[([\s\S]*?)\]\s*\)/;
7230
+ function masked(file) {
7231
+ const out = [];
7232
+ for (const line of file.lines) {
7233
+ let row = "";
7234
+ for (let c = 0; c < line.raw.length; c++) {
7235
+ const ch = line.raw[c];
7236
+ row += ch === " " || ch === " " || ch === "\r" || isCodeAt(file, line.n, c) ? ch : " ";
7237
+ }
7238
+ out.push(row);
7239
+ }
7240
+ return out.join("\n");
7241
+ }
7242
+ function rawText(file) {
7243
+ return file.lines.map((l) => l.raw).join("\n");
7244
+ }
7245
+ function lineAt(text, offset) {
7246
+ let n = 1;
7247
+ for (let i = 0; i < offset && i < text.length; i++) if (text.charCodeAt(i) === 10) n++;
7248
+ return n;
7249
+ }
7250
+ var OPEN = { "(": ")", "[": "]", "{": "}" };
7251
+ function matchClose(m, open) {
7252
+ const stack = [];
7253
+ for (let i = open; i < m.length; i++) {
7254
+ const ch = m[i];
7255
+ if (OPEN[ch] !== void 0) stack.push(OPEN[ch]);
7256
+ else if (ch === ")" || ch === "]" || ch === "}") {
7257
+ if (stack.pop() !== ch) return -1;
7258
+ if (stack.length === 0) return i;
7259
+ }
7260
+ }
7261
+ return -1;
7262
+ }
7263
+ function splitTop(m, from, to) {
7264
+ const parts = [];
7265
+ let depth = 0;
7266
+ let start = from;
7267
+ for (let i = from; i < to; i++) {
7268
+ const ch = m[i];
7269
+ if (OPEN[ch] !== void 0) depth++;
7270
+ else if (ch === ")" || ch === "]" || ch === "}") depth--;
7271
+ else if (ch === "," && depth === 0) {
7272
+ parts.push([start, i]);
7273
+ start = i + 1;
7274
+ }
7275
+ }
7276
+ if (start < to && m.slice(start, to).trim() !== "") parts.push([start, to]);
7277
+ return parts;
7278
+ }
7279
+ function listTs(dir, out = []) {
7280
+ let names;
7281
+ try {
7282
+ names = readdirSync7(dir);
7283
+ } catch {
7284
+ return out;
7285
+ }
7286
+ for (const n of names) {
7287
+ if (n === "node_modules" || n === "_generated" || n === "__tests__") continue;
7288
+ const p = join18(dir, n);
7289
+ let st;
7290
+ try {
7291
+ st = statSync8(p);
7292
+ } catch {
7293
+ continue;
7294
+ }
7295
+ if (st.isDirectory()) listTs(p, out);
7296
+ else if (n.endsWith(".ts") && !n.endsWith(".test.ts") && !n.endsWith(".d.ts")) out.push(p);
7297
+ }
7298
+ return out;
7299
+ }
7300
+ function discoverBackend(root) {
7301
+ let dir = root;
7302
+ try {
7303
+ if (!statSync8(root).isDirectory()) dir = dirname10(root);
7304
+ } catch {
7305
+ return void 0;
7306
+ }
7307
+ for (let up = 0; up < 5; up++) {
7308
+ const cand = join18(dir, "convex");
7309
+ if (existsSync15(join18(cand, "schema.ts"))) return cand;
7310
+ const parent = dirname10(dir);
7311
+ if (parent === dir) break;
7312
+ dir = parent;
7313
+ }
7314
+ return void 0;
7315
+ }
7316
+ function readDoors(backendRoot) {
7317
+ const doors = /* @__PURE__ */ new Map();
7318
+ for (const path of listTs(backendRoot)) {
7319
+ let text;
7320
+ try {
7321
+ text = readFileSync11(path, "utf8");
7322
+ } catch {
7323
+ continue;
7324
+ }
7325
+ const file = {
7326
+ path,
7327
+ rel: relative4(backendRoot, path),
7328
+ lines: text.split("\n").map((raw, i) => ({ n: i + 1, raw, comment: false }))
7329
+ };
7330
+ const m = masked(file);
7331
+ const module = relative4(backendRoot, path).replace(/\.ts$/, "").split("\\").join("/");
7332
+ const re = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:query|mutation|action)\s*\(/g;
7333
+ for (let hit = re.exec(m); hit; hit = re.exec(m)) {
7334
+ const open = hit.index + hit[0].length - 1;
7335
+ const close = matchClose(m, open);
7336
+ if (close < 0) continue;
7337
+ let start = hit.index;
7338
+ const lines = text.slice(0, start).split("\n");
7339
+ let k = lines.length - 2;
7340
+ while (k >= 0 && (lines[k].trim() === "" || /^\s*(\/\/|\/\*|\*)/.test(lines[k])) && lines.length - 2 - k < 60)
7341
+ k--;
7342
+ start = lines.slice(0, k + 1).join("\n").length;
7343
+ const region = text.slice(start, close + 1);
7344
+ const mRegion = m.slice(hit.index, close + 1);
7345
+ const claims = [];
7346
+ const am = /\bargs\s*:\s*/.exec(mRegion);
7347
+ if (am) {
7348
+ const at = hit.index + am.index + am[0].length;
7349
+ let argsText = "";
7350
+ if (m[at] === "{") argsText = text.slice(at, matchClose(m, at) + 1);
7351
+ else {
7352
+ const id = /^[A-Za-z_$][\w$]*/.exec(m.slice(at));
7353
+ if (id) {
7354
+ const dm = new RegExp(`\\bconst\\s+${id[0]}\\b[^=]*=\\s*`).exec(m);
7355
+ if (dm) {
7356
+ let p = dm.index + dm[0].length;
7357
+ if (m.slice(p).startsWith("v.object(")) p += "v.object(".length;
7358
+ if (m[p] === "{") argsText = text.slice(p, matchClose(m, p) + 1);
7359
+ }
7360
+ }
7361
+ }
7362
+ for (const c of argsText.match(ANY_CLAIM) ?? []) if (!claims.includes(c)) claims.push(c);
7363
+ }
7364
+ let exit;
7365
+ if (/\brequireServiceAccount\b/.test(mRegion))
7366
+ exit = "service-account-only (requireServiceAccount)";
7367
+ else if (/\brequireResolvedCaller\s*\([^)]*masterOnly[^)]*mcpBoundOnly|\brequireResolvedCaller\s*\([^)]*mcpBoundOnly[^)]*masterOnly/s.test(
7368
+ mRegion
7369
+ ))
7370
+ exit = "service-account-only (requireResolvedCaller masterOnly+mcpBoundOnly)";
7371
+ else {
7372
+ const fw = FLEET_WIDE.exec(region);
7373
+ if (fw) exit = `fleet-wide declaration (${fw[0].slice(11).trim().slice(0, 80)})`;
7374
+ }
7375
+ doors.set(`${module}:${hit[1]}`, {
7376
+ claims,
7377
+ exit,
7378
+ resolvesCaller: SCOPE_MARKERS.test(mRegion)
7379
+ });
7380
+ }
7381
+ }
7382
+ return doors;
7383
+ }
7384
+ function definitionOf(name, files) {
7385
+ const esc = name.replace(/\$/g, "\\$");
7386
+ const re = new RegExp(`(?:\\b(?:const|let|var)\\s+${esc}\\b|\\bfunction\\s+${esc}\\b)`);
7387
+ for (const f of files) {
7388
+ const hit = re.exec(f.m);
7389
+ if (!hit) continue;
7390
+ let depth = 0;
7391
+ for (let i = hit.index + hit[0].length; i < f.m.length; i++) {
7392
+ const ch = f.m[i];
7393
+ if (OPEN[ch] !== void 0) {
7394
+ depth++;
7395
+ } else if (ch === ")" || ch === "]" || ch === "}") {
7396
+ depth--;
7397
+ if (depth === 0 && ch === "}" && /\bfunction\b/.test(hit[0]))
7398
+ return { mask: f.m.slice(hit.index, i + 1) };
7399
+ } else if (ch === ";" && depth === 0 && !/\bfunction\b/.test(hit[0])) {
7400
+ return { mask: f.m.slice(hit.index, i + 1) };
7401
+ }
7402
+ if (depth < 0) break;
7403
+ }
7404
+ return { mask: f.m.slice(hit.index, Math.min(f.m.length, hit.index + 1500)) };
7405
+ }
7406
+ return void 0;
7407
+ }
7408
+ function readHelper(name, files, seen = /* @__PURE__ */ new Set(), depth = 0) {
7409
+ if (seen.has(name) || depth > 3) return { forwards: false, optional: false };
7410
+ seen.add(name);
7411
+ const def = definitionOf(name, files);
7412
+ if (!def) return { forwards: false, optional: false };
7413
+ let forwards = CLAIM_TOKEN.test(def.mask);
7414
+ const optional = /:\s*\{\s*\}|return\s*\{\s*\}|\?\?\s*\{\s*\}/.test(def.mask);
7415
+ if (!forwards) {
7416
+ for (const c of def.mask.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) {
7417
+ const sub = readHelper(c[1], files, seen, depth + 1);
7418
+ if (sub.forwards) {
7419
+ forwards = true;
7420
+ break;
7421
+ }
7422
+ }
7423
+ }
7424
+ return { forwards, optional };
7425
+ }
7426
+ function toolNameBefore(text, offset) {
7427
+ const head = text.slice(0, offset);
7428
+ const open = Math.max(head.lastIndexOf("defineTool("), head.lastIndexOf("registerTool("));
7429
+ if (open >= 0) {
7430
+ const m = /^\s*"([a-z][a-z0-9_]*)",?\s*$/m.exec(head.slice(open));
7431
+ if (m) return { name: m[1], start: open };
7432
+ const r = /registerTool\(\s*"([a-z][a-z0-9_]*)"/.exec(head.slice(open));
7433
+ if (r) return { name: r[1], start: open };
7434
+ return { name: void 0, start: open };
7435
+ }
7436
+ const lines = head.split("\n");
7437
+ const back = Math.max(0, lines.length - 120);
7438
+ return { name: void 0, start: lines.slice(0, back).join("\n").length };
7439
+ }
7440
+ function scanS28(tree) {
7441
+ const scan = { violate: [], conform: [], cnj: [], calls: 0, backend: void 0 };
7442
+ const backendRoot = tree.backendRoot ?? discoverBackend(tree.root);
7443
+ scan.backend = backendRoot;
7444
+ const doors = backendRoot ? readDoors(backendRoot) : void 0;
7445
+ const files = tree.files.map((f) => ({ f, text: rawText(f), m: masked(f) }));
7446
+ const actorDoors = /* @__PURE__ */ new Set();
7447
+ for (const { text } of files) {
7448
+ const hit = ACTOR_SET_RE.exec(text);
7449
+ if (hit) for (const s of hit[2].matchAll(/["'`]([\w/]+:[\w]+)["'`]/g)) actorDoors.add(s[1]);
7450
+ }
7451
+ for (const { f, text, m } of files) {
7452
+ for (let hit = CALL_RE.exec(m); hit; hit = CALL_RE.exec(m)) {
7453
+ const recv = hit[1];
7454
+ if (!/convex|client/i.test(recv)) continue;
7455
+ const open = hit.index + hit[0].length - 1;
7456
+ const close = matchClose(m, open);
7457
+ if (close < 0) continue;
7458
+ scan.calls++;
7459
+ const line = lineAt(text, hit.index);
7460
+ const tool = toolNameBefore(text, hit.index);
7461
+ const parts = splitTop(m, open + 1, close);
7462
+ let door;
7463
+ if (parts[0]) {
7464
+ const rawFirst = text.slice(parts[0][0], parts[0][1]);
7465
+ const lit = /^\s*(?:\/\/[^\n]*\n\s*)*["'`]([\w/]+:[\w]+)["'`]/.exec(rawFirst);
7466
+ const ref = /^\s*(?:api|internal)\.([\w]+)\.([\w]+)/.exec(rawFirst);
7467
+ if (lit) door = lit[1];
7468
+ else if (ref) door = `${ref[1]}:${ref[2]}`;
7469
+ }
7470
+ const site = (note) => ({
7471
+ file: f.rel,
7472
+ line,
7473
+ door: door ?? "?",
7474
+ tool: tool.name,
7475
+ note
7476
+ });
7477
+ if (door === void 0) {
7478
+ scan.cnj.push(
7479
+ site(
7480
+ "the door is named by an expression the scan cannot resolve (not statically resolvable)"
7481
+ )
7482
+ );
7483
+ continue;
7484
+ }
7485
+ const argText = parts[1] ? m.slice(parts[1][0], parts[1][1]) : "";
7486
+ const region = m.slice(tool.start, hit.index);
7487
+ const regionRaw = text.slice(tool.start, hit.index);
7488
+ const guard = GUARD_RE.test(region) || GUARD_RE.test(regionRaw);
7489
+ let direct = CLAIM_TOKEN.test(argText);
7490
+ let viaHelper;
7491
+ let virtual = argText;
7492
+ const bare = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(argText);
7493
+ if (bare && !direct) {
7494
+ const v = bare[1].replace(/\$/g, "\\$");
7495
+ const pieces = [];
7496
+ for (const a of region.matchAll(new RegExp(`Object\\.assign\\s*\\(\\s*${v}\\s*,`, "g"))) {
7497
+ const o = a.index + a[0].length - 1;
7498
+ const c = matchClose(region, region.lastIndexOf("(", o));
7499
+ pieces.push(region.slice(o, c < 0 ? o + 400 : c));
7500
+ }
7501
+ for (const a of region.matchAll(
7502
+ new RegExp(`\\b${v}\\.verified(?:Org|Person|Actor)\\b`, "g")
7503
+ ))
7504
+ pieces.push(a[0]);
7505
+ const decl = new RegExp(`\\b(?:const|let)\\s+${v}\\b[^=]*=\\s*\\{`).exec(region);
7506
+ if (decl) {
7507
+ const o = decl.index + decl[0].length - 1;
7508
+ const c = matchClose(region, o);
7509
+ pieces.push(region.slice(o, c < 0 ? o + 600 : c + 1));
7510
+ }
7511
+ virtual = pieces.join("\n");
7512
+ direct = CLAIM_TOKEN.test(virtual);
7513
+ }
7514
+ if (!direct) {
7515
+ for (const sp of virtual.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
7516
+ const h = readHelper(sp[1], files);
7517
+ if (h.forwards) {
7518
+ viaHelper = h;
7519
+ break;
7520
+ }
7521
+ }
7522
+ if (!viaHelper) {
7523
+ for (const sp of virtual.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(\s*\)/g)) {
7524
+ const h = readHelper(sp[1], files);
7525
+ if (h.forwards) {
7526
+ viaHelper = h;
7527
+ break;
7528
+ }
7529
+ }
7530
+ }
7531
+ }
7532
+ if (direct) {
7533
+ scan.conform.push(site("forwards the verified claim in the call"));
7534
+ continue;
7535
+ }
7536
+ if (viaHelper) {
7537
+ if (viaHelper.optional && !guard) {
7538
+ scan.violate.push(
7539
+ site(
7540
+ "forwards its claim through an optional helper (it returns {} when the bearer holds none) and no refusal precedes the call in the handler: a bearer with no resolvable organisation is sent claimless"
7541
+ )
7542
+ );
7543
+ } else {
7544
+ scan.conform.push(
7545
+ site(
7546
+ viaHelper.optional ? "forwards the verified claim through a helper and refuses before the call when it holds none" : "forwards the verified claim through a helper"
7547
+ )
7548
+ );
7549
+ }
7550
+ continue;
7551
+ }
7552
+ if (!doors) {
7553
+ scan.cnj.push(
7554
+ site(
7555
+ "forwards no verified claim and no backend source was found above the scanned tree (no convex/ with a schema.ts, and no backend root given): whether the door is an exit cannot be read"
7556
+ )
7557
+ );
7558
+ continue;
7559
+ }
7560
+ const info = doors.get(door);
7561
+ if (!info) {
7562
+ scan.cnj.push(
7563
+ site(
7564
+ `forwards no verified claim and the door ${door} is not defined in the supplied backend (${backendRoot})`
7565
+ )
7566
+ );
7567
+ continue;
7568
+ }
7569
+ if (info.exit) {
7570
+ scan.conform.push(site(`needs no claim: the door is ${info.exit}`));
7571
+ continue;
7572
+ }
7573
+ if (!info.resolvesCaller) {
7574
+ scan.cnj.push(
7575
+ site(
7576
+ `forwards no verified claim and the door ${door} shows no caller resolution in its own source (its scope comes from a helper or a run call that this scan does not follow)`
7577
+ )
7578
+ );
7579
+ continue;
7580
+ }
7581
+ const accepts = info.claims.length > 0 ? `the door accepts ${info.claims.join(", ")} and the tool omits it` : "the door accepts no claim argument, so R-54 must add one before the tool can forward it";
7582
+ const actorNote = actorDoors.has(door) ? "; verifiedActor is added by the wrapped client only for a bearer that resolved an agent, and a bearer that resolved no agent (a seat or person token, an org-level token) is sent claimless" : "";
7583
+ scan.violate.push(site(`forwards no verified claim \u2014 ${accepts}${actorNote}`));
7584
+ }
7585
+ }
7586
+ return scan;
7587
+ }
7588
+ function fmt(s) {
7589
+ return `${s.file}:${s.line} ${s.door} ${s.tool ? `[${s.tool}] ` : ""}(${s.note})`;
7590
+ }
7591
+ var S28_ID = "S-28 claim-forwarded";
7592
+ var s28ClaimForwarded = {
7593
+ id: S28_ID,
7594
+ rule: "S-28",
7595
+ pin: PIN11,
7596
+ run(tree) {
7597
+ const s = scanS28(tree);
7598
+ const breakdown = `[conform=${s.conform.length} non-conforming=${s.violate.length} could-not-judge=${s.cnj.length}]`;
7599
+ const list = (xs) => xs.slice(0, 5).map(fmt).join("; ");
7600
+ if (s.violate.length > 0) {
7601
+ const first = s.violate[0];
7602
+ return {
7603
+ id: S28_ID,
7604
+ anchor: `${first.file}:${first.line}`,
7605
+ state: "non-conforming",
7606
+ verdict: `S-28: ${s.violate.length} backend call(s) VIOLATE (a tool forwards the verified claim of its caller to every backend door it calls, and refuses when it has none). ${breakdown} Violations: ${list(s.violate)}${s.violate.length > 5 ? `; +${s.violate.length - 5} more` : ""}.${s.cnj.length ? ` could-not-judge calls (a verdict is owed): ${list(s.cnj)}.` : ""}`
7607
+ };
7608
+ }
7609
+ if (s.cnj.length > 0) {
7610
+ const reason2 = `S-28: ${s.cnj.length} backend call(s) could not be judged \u2014 ${list(s.cnj)}. This is NOT a clean bill. ${breakdown}`;
7611
+ return {
7612
+ id: S28_ID,
7613
+ anchor: `${s.cnj[0].file}:${s.cnj[0].line}`,
7614
+ state: "could-not-judge",
7615
+ verdict: reason2,
7616
+ reason: reason2
7617
+ };
7618
+ }
7619
+ if (s.conform.length > 0) {
7620
+ return {
7621
+ id: S28_ID,
7622
+ anchor: `${s.conform[0].file}:${s.conform[0].line}`,
7623
+ state: "present",
7624
+ verdict: `S-28: ${s.conform.length} backend call(s) forward the verified claim or call a door that needs none; 0 violations. ${breakdown} Conform: ${list(s.conform)}.`
7625
+ };
7626
+ }
7627
+ const usesBackend = tree.files.some(
7628
+ (f) => f.lines.some((l) => !l.comment && /\bConvexHttpClient\b|from\s+["']convex\//.test(l.raw))
7629
+ );
7630
+ if (!usesBackend) {
7631
+ return {
7632
+ id: S28_ID,
7633
+ anchor: "source:no-backend-client",
7634
+ state: "absent",
7635
+ verdict: `S-28: no Convex client is imported anywhere in the ${tree.files.length} file(s) read; no backend door is called, so there is no claim to forward (rule out of subject).`
7636
+ };
7637
+ }
7638
+ const reason = `S-28: no applicable site \u2014 a Convex client is imported but none of the ${s.calls} <client>.query|mutation|action call(s) read names a door statically. NOT a clean bill.`;
7639
+ return {
7640
+ id: S28_ID,
7641
+ anchor: "source:no-applicable-site",
7642
+ state: "could-not-judge",
7643
+ verdict: reason,
7644
+ reason
7645
+ };
7646
+ }
7647
+ };
7648
+
7218
7649
  // src/detectors/index.ts
7219
7650
  var detectors = [
7220
7651
  d2,
@@ -7248,6 +7679,7 @@ var detectors = [
7248
7679
  c22TestRatioGate,
7249
7680
  c25BuildIdentity,
7250
7681
  c26SubpackageLockfile,
7682
+ s28ClaimForwarded,
7251
7683
  dServer2,
7252
7684
  dServer6,
7253
7685
  dDebugOptIn,
@@ -7267,18 +7699,18 @@ var detectors = [
7267
7699
 
7268
7700
  // src/runtime/launch.ts
7269
7701
  import { spawnSync as spawnSync2 } from "child_process";
7270
- import { existsSync as existsSync15, readFileSync as readFileSync11, statSync as statSync8 } from "fs";
7271
- import { dirname as dirname10, isAbsolute, join as join18, resolve } from "path";
7702
+ import { existsSync as existsSync16, readFileSync as readFileSync12, statSync as statSync9 } from "fs";
7703
+ import { dirname as dirname11, isAbsolute, join as join19, resolve } from "path";
7272
7704
  function findPackageDir(target) {
7273
7705
  let dir;
7274
7706
  try {
7275
- dir = statSync8(target).isDirectory() ? target : dirname10(target);
7707
+ dir = statSync9(target).isDirectory() ? target : dirname11(target);
7276
7708
  } catch {
7277
7709
  return null;
7278
7710
  }
7279
7711
  for (let depth = 0; depth < 8; depth++) {
7280
- if (existsSync15(join18(dir, "package.json"))) return dir;
7281
- const parent = dirname10(dir);
7712
+ if (existsSync16(join19(dir, "package.json"))) return dir;
7713
+ const parent = dirname11(dir);
7282
7714
  if (parent === dir) break;
7283
7715
  dir = parent;
7284
7716
  }
@@ -7302,10 +7734,10 @@ function deriveLaunchPlan(target) {
7302
7734
  }
7303
7735
  let pkg;
7304
7736
  try {
7305
- pkg = JSON.parse(readFileSync11(join18(pkgDir, "package.json"), "utf8"));
7737
+ pkg = JSON.parse(readFileSync12(join19(pkgDir, "package.json"), "utf8"));
7306
7738
  } catch (err) {
7307
7739
  return {
7308
- error: `could not parse ${join18(pkgDir, "package.json")}: ${err instanceof Error ? err.message : String(err)}`
7740
+ error: `could not parse ${join19(pkgDir, "package.json")}: ${err instanceof Error ? err.message : String(err)}`
7309
7741
  };
7310
7742
  }
7311
7743
  const bin = firstBinEntry(pkg.bin);
@@ -7383,10 +7815,10 @@ function readEvalCounts(target) {
7383
7815
  if (!pkgDir) return counts;
7384
7816
  let dir = pkgDir;
7385
7817
  for (let depth = 0; depth < 8; depth++) {
7386
- const candidate = join18(dir, "evals", "evals.json");
7387
- if (existsSync15(candidate)) {
7818
+ const candidate = join19(dir, "evals", "evals.json");
7819
+ if (existsSync16(candidate)) {
7388
7820
  try {
7389
- const parsed = JSON.parse(readFileSync11(candidate, "utf8"));
7821
+ const parsed = JSON.parse(readFileSync12(candidate, "utf8"));
7390
7822
  for (const c of parsed.cases ?? []) {
7391
7823
  if (c && typeof c.tool === "string") counts.set(c.tool, (counts.get(c.tool) ?? 0) + 1);
7392
7824
  }
@@ -7394,7 +7826,7 @@ function readEvalCounts(target) {
7394
7826
  }
7395
7827
  return counts;
7396
7828
  }
7397
- const parent = dirname10(dir);
7829
+ const parent = dirname11(dir);
7398
7830
  if (parent === dir) break;
7399
7831
  dir = parent;
7400
7832
  }
@@ -7402,7 +7834,7 @@ function readEvalCounts(target) {
7402
7834
  }
7403
7835
  function statSyncSafe(p) {
7404
7836
  try {
7405
- return statSync8(p).isDirectory();
7837
+ return statSync9(p).isDirectory();
7406
7838
  } catch {
7407
7839
  return false;
7408
7840
  }
@@ -7525,7 +7957,7 @@ function runDoctor(target, detectorList = detectors, opts = {}) {
7525
7957
  }
7526
7958
  function runDoctorReport(target, detectorList = detectors, opts = {}) {
7527
7959
  const mode = opts.mode ?? "static";
7528
- const tree = loadTree(target);
7960
+ const tree = loadTree(target, opts.backend);
7529
7961
  const needsRuntime = mode !== "static" && detectorList.some((d) => typeof d.runDynamic === "function");
7530
7962
  const runtime = needsRuntime ? launchAndIntrospect(target) : null;
7531
7963
  const results = [];
@@ -7654,7 +8086,7 @@ function computeExitCode(results) {
7654
8086
  return 0;
7655
8087
  }
7656
8088
  var USAGE = [
7657
- "usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both]",
8089
+ "usage: mcp-doctor <source-tree-path> [--mode=static|dynamic|both] [--backend=<convex-dir>]",
7658
8090
  "",
7659
8091
  " --mode=static read the tree only (default)",
7660
8092
  " --mode=dynamic launch the server and judge what it serves",
@@ -7662,6 +8094,8 @@ var USAGE = [
7662
8094
  " --local-identity-ceiling=N",
7663
8095
  " local identity primitives tolerated before DLocalIdentity",
7664
8096
  " fails (default 0)",
8097
+ " --backend=DIR the backend source (a convex/ dir) whose door list S-28 reads;",
8098
+ " default: the nearest convex/ above the tree",
7665
8099
  " --version print the version and exit",
7666
8100
  " --help print this and exit",
7667
8101
  "",
@@ -7669,8 +8103,8 @@ var USAGE = [
7669
8103
  ].join("\n");
7670
8104
  function readVersion() {
7671
8105
  try {
7672
- const here = dirname11(fileURLToPath(import.meta.url));
7673
- const manifest = JSON.parse(readFileSync12(join19(here, "..", "package.json"), "utf8"));
8106
+ const here = dirname12(fileURLToPath(import.meta.url));
8107
+ const manifest = JSON.parse(readFileSync13(join20(here, "..", "package.json"), "utf8"));
7674
8108
  return manifest.version ?? null;
7675
8109
  } catch {
7676
8110
  return null;
@@ -7692,6 +8126,7 @@ function main() {
7692
8126
  return;
7693
8127
  }
7694
8128
  const modeArg = args.find((a) => a.startsWith("--mode="));
8129
+ const backendArg = args.find((a) => a.startsWith("--backend="));
7695
8130
  const target = args.find((a) => !a.startsWith("--"));
7696
8131
  if (!target) {
7697
8132
  console.error(USAGE);
@@ -7714,7 +8149,11 @@ function main() {
7714
8149
  const ratchet = makeLocalIdentity({ ceiling });
7715
8150
  detectorList = detectors.map((d) => d === localIdentity ? ratchet : d);
7716
8151
  }
7717
- const { results, skipped } = runDoctorReport(target, detectorList, { mode });
8152
+ const backend = backendArg ? backendArg.slice("--backend=".length) : void 0;
8153
+ const { results, skipped } = runDoctorReport(target, detectorList, {
8154
+ mode,
8155
+ ...backend !== void 0 ? { backend } : {}
8156
+ });
7718
8157
  console.log(renderTable(target, results, skipped));
7719
8158
  process.exitCode = computeExitCode(results);
7720
8159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-doctor",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Static MCP conformance checker: audits an MCP server source tree against the MCP 2026-07-28 standard and emits a conformance-diff table (file:line | present | absent | non-conforming | verdict).",
5
5
  "type": "module",
6
6
  "main": "./dist/doctor.js",