@vantageos/mcp-doctor 0.1.0 → 0.3.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 ADDED
@@ -0,0 +1,79 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@vantageos/mcp-doctor` are documented here.
4
+ This project adheres to [Semantic Versioning](https://semver.org/).
5
+
6
+ ## 0.3.0 — 2026-10-04
7
+
8
+ The documented invocation never ran. Through `node_modules/.bin/mcp-doctor` this
9
+ tool printed **nothing** and exited **0** — for every target, since the bin was
10
+ published. Every station that ran `npx mcp-doctor <tree>` and saw no output read a
11
+ pass that was never computed.
12
+
13
+ ### READ THIS BEFORE UPGRADING
14
+
15
+ **A CI job that was passing may now fail, and that is the fix working.** If you
16
+ wired `npx mcp-doctor` into a pipeline and it has been green, it was green because
17
+ nothing ran. On a real target this tool has findings to report. Budget for a first
18
+ red run.
19
+
20
+ ### Fixed
21
+ - **The bin is a symlink, and the entrypoint guard compared raw paths.** npm installs
22
+ `bin` as a symlink, so `process.argv[1]` is the link path while `import.meta.url`
23
+ is the resolved real path; compared directly they never match and `main()` never
24
+ ran. Both sides now resolve through `realpathSync`, each side guarded: a path that
25
+ cannot be resolved answers "do not run" rather than either running on an ordinary
26
+ library import or failing silently.
27
+ Measured on the published 0.2.0 before the fix — `./node_modules/.bin/mcp-doctor t`
28
+ exit 0 / 0 bytes against `node .../dist/doctor.js t` exit 2 / 6308 bytes.
29
+ - **`--version`, `--help` and `-V` were silent for the same reason** and now answer.
30
+ The version is READ from the shipped manifest rather than compiled in, so it cannot
31
+ drift from the binary.
32
+ - **The usage line named a development command no consumer can type**
33
+ (`tsx src/doctor.ts`). It now names `mcp-doctor` and lists the exit codes.
34
+
35
+ ### Added
36
+ - **D16 is judged at runtime, not only statically.** Health-route census plus a dynamic
37
+ mode: the version a server actually SERVES on its pre-auth `initialize` handshake is
38
+ read from `serverInfo.version` and compared against `package.json`. A served version
39
+ that drifts from the canonical one is now a finding rather than invisible.
40
+ - A static D16 verdict says out loud which mode produced it (`[static read]` prefix), so
41
+ a reader can tell an observation from a fallback.
42
+
43
+ ### Changed — observable contract
44
+ - D16's static verdict strings carry the `[static read]` prefix. Anything parsing that
45
+ text rather than the `state` field must be updated.
46
+
47
+ ---
48
+
49
+ ## 0.2.0 — 2026-08-16
50
+
51
+ The engine gains a live-session contract and an honest exit code; a second
52
+ doctor can now consume it as a dependency rather than copy it.
53
+
54
+ ### Added
55
+ - **Live-session contract** (additive, no existing detector changed): `Session`
56
+ (`listTools` / `discover` / `callTool`), `LiveDetector` (`runLive(session)`),
57
+ `isLiveDetector`, and the async runner `runDoctorAsync(target, { session?, detectorList? })`.
58
+ A control that must call a running server is now expressible. A session that is
59
+ unreachable, times out, or refuses returns `could-not-judge` with a reason —
60
+ never a pass, never a violation. All four states are exported.
61
+ - **`computeExitCode`** and a result-driven process exit in the CLI.
62
+
63
+ ### Changed — observable contract
64
+ - **Exit code.** The CLI previously exited `0` on every run. It now derives the
65
+ code from the results: **`1`** if any result is `non-conforming` (precedence),
66
+ else **`2`** if any result is `could-not-judge`, else **`0`**.
67
+
68
+ **What exit `0` means, exactly:** no violation and no abstention was *reached*.
69
+ It is **not** a certificate of conformance — the same `0` is produced by a tree
70
+ the doctor cannot read at all, and by one whose rules are all `absent` (nothing
71
+ of that kind present to judge). A consumer must read `0` as "nothing raised the
72
+ code", not as "conformant". A judged-conformant demonstration tree is tracked
73
+ as follow-up; until then the `0` pole is guarded by the unit suite, not by a
74
+ fixture.
75
+
76
+ ### Notes
77
+ - The fourth state `could-not-judge` (a detector that could not read what it was
78
+ pointed at, with a required reason) is distinct from `absent`; the exit code
79
+ now lets a machine caller tell an abstention from a clean run.
package/README.md CHANGED
@@ -59,6 +59,10 @@ Each detector maps one standard item and is proven **bipolar** on real material:
59
59
  | **D5** server/discover | a `server/discover` handler is registered (absence-based, with a positive control) | present / absent |
60
60
  | **D6** mcp-apps ui-key | MCP Apps UI key is the GA nested `_meta.ui.resourceUri` — not flat `ui/resourceUri`, not community `io.modelcontextprotocol/ui` | GA-nested / flat-deprecated / community-nonGA / none |
61
61
  | **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 |
62
+ | **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 |
63
+ | **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 |
64
+
65
+ The registered detector set is the source of truth (`src/detectors/index.ts`); the table above is illustrative, not the count. **Adding a detector is how a friction becomes durable — see [CONTRIBUTING.md](./CONTRIBUTING.md) for the loop.**
62
66
 
63
67
  ## Positioning
64
68
 
package/dist/doctor.d.ts CHANGED
@@ -1,14 +1,250 @@
1
1
  #!/usr/bin/env node
2
- type State = "present" | "absent" | "non-conforming";
2
+ interface SourceLine {
3
+ n: number;
4
+ raw: string;
5
+ /** true when the line is a comment-only line (JSDoc star, slash-slash, block open/close). */
6
+ comment: boolean;
7
+ }
8
+ interface SourceFile {
9
+ path: string;
10
+ rel: string;
11
+ lines: SourceLine[];
12
+ }
13
+ interface Tree {
14
+ root: string;
15
+ files: SourceFile[];
16
+ packageJson: {
17
+ path: string;
18
+ json: Record<string, unknown>;
19
+ } | null;
20
+ }
21
+
22
+ type State = "present" | "absent" | "non-conforming" | "could-not-judge";
3
23
  interface DetectorResult {
4
24
  id: string;
5
25
  /** display anchor(s) file:line — the site the verdict is about. */
6
26
  anchor: string;
7
27
  state: State;
8
28
  verdict: string;
29
+ /**
30
+ * Machine-readable reason the detector could not decide. REQUIRED whenever
31
+ * `state === "could-not-judge"`: a could-not-judge with no reason is a shrug,
32
+ * and the runner refuses it. Ignored for the three decisive states.
33
+ */
34
+ reason?: string;
35
+ }
36
+ /** A single tool as reported by the RUNNING server over the MCP `tools/list` call. */
37
+ interface RuntimeTool {
38
+ /** The tool name the server actually advertises at runtime (post-startup). */
39
+ name: string;
40
+ /**
41
+ * The description the server actually SERVES over `tools/list`, post-
42
+ * startup — the surface C-19 measures (never the source-declared string,
43
+ * which can drift from what a real client receives). Unset only when the
44
+ * running server declares no description for the tool.
45
+ */
46
+ description?: string;
47
+ /**
48
+ * Number of eval cases covering this tool, resolved by the runtime builder
49
+ * from the target's `evals/evals.json` (matched by tool name). Optional
50
+ * because the eval file may be absent; a tool present at runtime with no
51
+ * eval file has `evalCaseCount` unset, which D8 reads as zero coverage.
52
+ */
53
+ evalCaseCount?: number;
54
+ }
55
+ /**
56
+ * What the RUNNING target server exposes, gathered by launching it and speaking
57
+ * MCP over stdio (`initialize` -> `initialized` -> `tools/list`). This is the
58
+ * surface the static scan cannot see: names computed at startup, tools shipped
59
+ * only as built `dist` with no readable source.
60
+ *
61
+ * `launchError` is set (and `tools` is empty) whenever the server could not be
62
+ * started or introspected — a spawn failure, a handshake failure, a timeout.
63
+ * A dynamic detector reads a set `launchError` as `could-not-judge` (naming the
64
+ * failure), NEVER as a pass and NEVER as absent.
65
+ */
66
+ interface RuntimeContext {
67
+ /**
68
+ * The target root the runtime was built from. Carried so a dynamic detector
69
+ * can locate static co-artifacts (e.g. D8's `evals/evals.json`) without a
70
+ * second argument — the decided `runDynamic(runtime)` signature stays single.
71
+ */
72
+ root: string;
73
+ tools: RuntimeTool[];
74
+ launchError?: string;
75
+ /**
76
+ * The `serverInfo` object the running server returned in its `initialize`
77
+ * response — the same non-authenticated discovery handshake a client reads
78
+ * `version` from (S-7). Additive field: existing dynamic detectors that
79
+ * never read it are unaffected. C-25 reads it for a build-identity field
80
+ * served alongside `version` (commit hash / build id). Unset when the
81
+ * launched server returned no `serverInfo` or the launch failed
82
+ * (`launchError` is then set instead).
83
+ */
84
+ serverInfo?: Record<string, unknown>;
85
+ /**
86
+ * The commit the running server is EXPECTED to be serving, DERIVED from
87
+ * `git rev-parse HEAD` in the target tree — never hand-typed (see
88
+ * derive-never-type). Unset when the target is not a resolvable git
89
+ * checkout: C-25's dynamic served-vs-expected comparison is then reported
90
+ * as OWED (could-not-judge), never silently skipped as a pass.
91
+ * When set it is ALWAYS a full 40-hex commit id (`/^[0-9a-f]{40}$/`,
92
+ * enforced by `deriveExpectedCommit`): C-25's comparison reasons about
93
+ * length and asserts that shape rather than assuming it.
94
+ */
95
+ expectedCommit?: string;
9
96
  }
97
+ interface Detector {
98
+ id: string;
99
+ /**
100
+ * The canonical standard rule id this detector judges (e.g. "C-6"). Optional
101
+ * so the pre-existing detectors that predate this field still compile; every
102
+ * detector built or re-pinned against a standard rule MUST populate it.
103
+ * Coverage of the standard is DERIVED from this field across the registry
104
+ * (see `scripts/trunk-coverage.ts`) — never from a hand-kept list.
105
+ */
106
+ rule?: string;
107
+ /** The standard commit this detector's `rule` was read against. Required
108
+ * alongside `rule`: a rule id with no pin is unverifiable against a moving
109
+ * doc. */
110
+ pin?: string;
111
+ /** Static verdict from the source tree. REQUIRED — static is never optional. */
112
+ run(tree: Tree): DetectorResult;
113
+ /**
114
+ * Optional dynamic verdict from the RUNNING server. A detector that can judge
115
+ * something only visible at runtime (a computed tool name, the real tool set)
116
+ * implements this; detectors without it run static-only in every mode.
117
+ */
118
+ runDynamic?(runtime: RuntimeContext): DetectorResult;
119
+ }
120
+ /**
121
+ * A minimal handle to a RUNNING MCP session — a live client the consumer has
122
+ * already connected (`initialize` -> `initialized`). Where `RuntimeContext` is
123
+ * a one-shot SNAPSHOT (launch the server, read `tools/list` once), a `Session`
124
+ * is an INTERACTIVE handle a detector drives: it can call tools and read served
125
+ * responses at will. This is the surface the ~21 MCP + 4 backend *dynamique*
126
+ * rules need — the ones a static tree read and a single tool-list snapshot both
127
+ * cannot express (served-response conformance, pagination, auth challenge).
128
+ *
129
+ * Kept deliberately small and extensible: three verbs — `listTools` (the served
130
+ * `tools/list` payload), `discover` (the served discovery / description payload),
131
+ * `callTool` (one tool invoked, its served response or auth challenge read).
132
+ * These reach the *dynamique* rules expressible by an argument-less list read, a
133
+ * discovery read, and a single single-identity tool call — e.g. S-2 / S-4 served
134
+ * responses and C-19 served descriptions.
135
+ *
136
+ * What these three verbs deliberately do NOT yet reach — named so the surface is
137
+ * not credited in advance for a rule it cannot express:
138
+ * - S-12 (pagination): `listTools()` takes no cursor, so a page cannot be
139
+ * requested — needs a cursor argument.
140
+ * - S-3 (`CacheableResult` on the five list/read surfaces): needs the four
141
+ * non-tool list/read surfaces, not only `tools/list`.
142
+ * - S-6 (tools/list coherence): needs an emitted `list_changed` notification to
143
+ * observe across an emission, not a single snapshot.
144
+ * - S-5 (OAuth 2.1 resource server) and backend R-6 / R-11: need the same call made
145
+ * under varied credentials / as two principals — `callTool` carries no
146
+ * identity.
147
+ * A rule that needs one of these adds its verb here in one additive edit; the
148
+ * surface stays honest about what it can read today, and nothing is pre-invented
149
+ * for a rule that does not yet read it.
150
+ *
151
+ * Every method is async and MAY reject (connection refused, timeout, protocol
152
+ * error). A `LiveDetector` reading a rejecting session returns `could-not-judge`
153
+ * naming the failure — never a pass, never a violation — exactly as a dynamic
154
+ * detector reads a set `launchError`.
155
+ */
156
+ interface Session {
157
+ /** The served `tools/list` payload (post-startup, as the client receives it). */
158
+ listTools(): Promise<unknown>;
159
+ /** The served discovery / description payload the client reads at connect. */
160
+ discover(): Promise<unknown>;
161
+ /** Invoke one tool and read the served response (or the auth challenge it returns). */
162
+ callTool(name: string, args: unknown): Promise<unknown>;
163
+ }
164
+ /**
165
+ * A detector whose verdict requires CALLING a running server — a live session,
166
+ * not a static tree and not a one-shot snapshot. It is a SEPARATE contract from
167
+ * `Detector`: the async runner routes an object to `runLive` iff it carries one,
168
+ * and to `run(tree)` otherwise. No existing `Detector` is touched or migrated.
169
+ */
170
+ interface LiveDetector {
171
+ id: string;
172
+ /** See `Detector.rule` — same contract, same derivation. */
173
+ rule?: string;
174
+ /** See `Detector.pin` — same contract. */
175
+ pin?: string;
176
+ /**
177
+ * Live verdict from a running session. REQUIRED for a live detector. It owns
178
+ * the abstain contract: a session it cannot read (absent, throwing, timing
179
+ * out before anything is read) yields `could-not-judge` with a non-empty
180
+ * `reason` — never a pass, never a violation, both directions.
181
+ */
182
+ runLive(session: Session): Promise<DetectorResult>;
183
+ }
184
+ /**
185
+ * Guard: an object is a `LiveDetector` iff it exposes a `runLive` function. The
186
+ * domain of "kinds of detector" is exactly two (tree `run`, live `runLive`);
187
+ * this guard covers it, and the async runner routes on it.
188
+ */
189
+ declare function isLiveDetector(d: Detector | LiveDetector): d is LiveDetector;
10
190
 
11
- declare function runDoctor(target: string): DetectorResult[];
191
+ type RunMode = "static" | "dynamic" | "both";
192
+ interface RunDoctorOptions {
193
+ mode?: RunMode;
194
+ }
195
+ /**
196
+ * Run every detector against the target.
197
+ *
198
+ * Modes (default `static`):
199
+ * - `static` — the default; every detector's `run(tree)`. Behaviour and
200
+ * output are IDENTICAL to before dynamic mode existed.
201
+ * - `dynamic` — the running server is introspected once; a dynamic-capable
202
+ * detector runs `runDynamic(runtime)`, every other detector runs `run(tree)`.
203
+ * - `both` — static for all detectors PLUS dynamic for the dynamic-capable;
204
+ * when a rule's static and dynamic `state` DIFFER, a distinct disagreement
205
+ * result is emitted naming BOTH states and BOTH anchors. The modes never
206
+ * disagree silently.
207
+ *
208
+ * The runner still fails LOUD on its own coverage: a detector that throws aborts
209
+ * the run; a could-not-judge with no reason is refused; every detector produces
210
+ * at least one well-formed result.
211
+ *
212
+ * `detectorList` is injectable for tests; production uses the full registry.
213
+ */
214
+ declare function runDoctor(target: string, detectorList?: Detector[], opts?: RunDoctorOptions): DetectorResult[];
215
+ /**
216
+ * Async runner — the ADDITIVE extension for *dynamique* rules that must CALL a
217
+ * running server. It is a superset of the sync `runDoctor` static path, never a
218
+ * replacement:
219
+ * - a tree `Detector` runs via `run(tree)`, byte-identical to `runDoctor`;
220
+ * - a `LiveDetector` runs via `await runLive(session)`;
221
+ * - a live detector with no session, or whose session throws/times out/refuses
222
+ * before reading anything, yields `could-not-judge` with a non-empty reason
223
+ * (never a pass, never a violation, both directions).
224
+ *
225
+ * The fail-loud discipline is identical: a tree detector that throws aborts the
226
+ * run naming the detector; a could-not-judge with no reason is refused; the
227
+ * coverage-count check asserts exactly one result per detector.
228
+ *
229
+ * The sync `runDoctor`, every registered detector, and `scan.ts` are untouched
230
+ * — a tree-only `runDoctorAsync(target)` returns exactly what `runDoctor(target)`
231
+ * returns.
232
+ */
233
+ declare function runDoctorAsync(target: string, opts?: {
234
+ session?: Session;
235
+ detectorList?: (Detector | LiveDetector)[];
236
+ }): Promise<DetectorResult[]>;
12
237
  declare function renderTable(surface: string, results: DetectorResult[]): string;
238
+ /**
239
+ * The process exit code a run's results carry, three-way with precedence:
240
+ * 1 — a VIOLATION: at least one detector is `non-conforming` (something IS
241
+ * wrong). Takes precedence over an abstention.
242
+ * 2 — an ABSTENTION with zero violations: at least one `could-not-judge`
243
+ * ("I did not look" must NOT reach a caller as a clean 0).
244
+ * 0 — fully judged and conformant: nothing non-conforming, nothing abstained.
245
+ * The rendered table already draws this distinction; this only lets a machine
246
+ * reading the exit code (CI, the foreign-bite probes) see what the table says.
247
+ */
248
+ declare function computeExitCode(results: DetectorResult[]): number;
13
249
 
14
- export { renderTable, runDoctor };
250
+ export { type LiveDetector, type RunDoctorOptions, type RunMode, type Session, computeExitCode, isLiveDetector, renderTable, runDoctor, runDoctorAsync };