@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 +17 -12
- package/README.md +4 -1
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.js +459 -20
- package/package.json +1 -1
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
|
-
##
|
|
6
|
+
## 0.5.0 — unreleased
|
|
7
7
|
|
|
8
|
-
###
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
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
|
|
5
|
-
import { dirname as
|
|
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
|
|
7271
|
-
import { dirname as
|
|
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 =
|
|
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 (
|
|
7281
|
-
const parent =
|
|
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(
|
|
7737
|
+
pkg = JSON.parse(readFileSync12(join19(pkgDir, "package.json"), "utf8"));
|
|
7306
7738
|
} catch (err) {
|
|
7307
7739
|
return {
|
|
7308
|
-
error: `could not parse ${
|
|
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 =
|
|
7387
|
-
if (
|
|
7818
|
+
const candidate = join19(dir, "evals", "evals.json");
|
|
7819
|
+
if (existsSync16(candidate)) {
|
|
7388
7820
|
try {
|
|
7389
|
-
const parsed = JSON.parse(
|
|
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 =
|
|
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
|
|
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 =
|
|
7673
|
-
const manifest = JSON.parse(
|
|
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
|
|
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.
|
|
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",
|