@vantageos/mcp-doctor 0.1.0 → 0.2.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,36 @@
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.2.0 — 2026-08-16
7
+
8
+ The engine gains a live-session contract and an honest exit code; a second
9
+ doctor can now consume it as a dependency rather than copy it.
10
+
11
+ ### Added
12
+ - **Live-session contract** (additive, no existing detector changed): `Session`
13
+ (`listTools` / `discover` / `callTool`), `LiveDetector` (`runLive(session)`),
14
+ `isLiveDetector`, and the async runner `runDoctorAsync(target, { session?, detectorList? })`.
15
+ A control that must call a running server is now expressible. A session that is
16
+ unreachable, times out, or refuses returns `could-not-judge` with a reason —
17
+ never a pass, never a violation. All four states are exported.
18
+ - **`computeExitCode`** and a result-driven process exit in the CLI.
19
+
20
+ ### Changed — observable contract
21
+ - **Exit code.** The CLI previously exited `0` on every run. It now derives the
22
+ code from the results: **`1`** if any result is `non-conforming` (precedence),
23
+ else **`2`** if any result is `could-not-judge`, else **`0`**.
24
+
25
+ **What exit `0` means, exactly:** no violation and no abstention was *reached*.
26
+ It is **not** a certificate of conformance — the same `0` is produced by a tree
27
+ the doctor cannot read at all, and by one whose rules are all `absent` (nothing
28
+ of that kind present to judge). A consumer must read `0` as "nothing raised the
29
+ code", not as "conformant". A judged-conformant demonstration tree is tracked
30
+ as follow-up; until then the `0` pole is guarded by the unit suite, not by a
31
+ fixture.
32
+
33
+ ### Notes
34
+ - The fourth state `could-not-judge` (a detector that could not read what it was
35
+ pointed at, with a required reason) is distinct from `absent`; the exit code
36
+ 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,206 @@
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
+ * Number of eval cases covering this tool, resolved by the runtime builder
42
+ * from the target's `evals/evals.json` (matched by tool name). Optional
43
+ * because the eval file may be absent; a tool present at runtime with no
44
+ * eval file has `evalCaseCount` unset, which D8 reads as zero coverage.
45
+ */
46
+ evalCaseCount?: number;
47
+ }
48
+ /**
49
+ * What the RUNNING target server exposes, gathered by launching it and speaking
50
+ * MCP over stdio (`initialize` -> `initialized` -> `tools/list`). This is the
51
+ * surface the static scan cannot see: names computed at startup, tools shipped
52
+ * only as built `dist` with no readable source.
53
+ *
54
+ * `launchError` is set (and `tools` is empty) whenever the server could not be
55
+ * started or introspected — a spawn failure, a handshake failure, a timeout.
56
+ * A dynamic detector reads a set `launchError` as `could-not-judge` (naming the
57
+ * failure), NEVER as a pass and NEVER as absent.
58
+ */
59
+ interface RuntimeContext {
60
+ /**
61
+ * The target root the runtime was built from. Carried so a dynamic detector
62
+ * can locate static co-artifacts (e.g. D8's `evals/evals.json`) without a
63
+ * second argument — the decided `runDynamic(runtime)` signature stays single.
64
+ */
65
+ root: string;
66
+ tools: RuntimeTool[];
67
+ launchError?: string;
9
68
  }
69
+ interface Detector {
70
+ id: string;
71
+ /** Static verdict from the source tree. REQUIRED — static is never optional. */
72
+ run(tree: Tree): DetectorResult;
73
+ /**
74
+ * Optional dynamic verdict from the RUNNING server. A detector that can judge
75
+ * something only visible at runtime (a computed tool name, the real tool set)
76
+ * implements this; detectors without it run static-only in every mode.
77
+ */
78
+ runDynamic?(runtime: RuntimeContext): DetectorResult;
79
+ }
80
+ /**
81
+ * A minimal handle to a RUNNING MCP session — a live client the consumer has
82
+ * already connected (`initialize` -> `initialized`). Where `RuntimeContext` is
83
+ * a one-shot SNAPSHOT (launch the server, read `tools/list` once), a `Session`
84
+ * is an INTERACTIVE handle a detector drives: it can call tools and read served
85
+ * responses at will. This is the surface the ~21 MCP + 4 backend *dynamique*
86
+ * rules need — the ones a static tree read and a single tool-list snapshot both
87
+ * cannot express (served-response conformance, pagination, auth challenge).
88
+ *
89
+ * Kept deliberately small and extensible: three verbs — `listTools` (the served
90
+ * `tools/list` payload), `discover` (the served discovery / description payload),
91
+ * `callTool` (one tool invoked, its served response or auth challenge read).
92
+ * These reach the *dynamique* rules expressible by an argument-less list read, a
93
+ * discovery read, and a single single-identity tool call — e.g. S-2 / S-4 served
94
+ * responses and C-19 served descriptions.
95
+ *
96
+ * What these three verbs deliberately do NOT yet reach — named so the surface is
97
+ * not credited in advance for a rule it cannot express:
98
+ * - S-12 (pagination): `listTools()` takes no cursor, so a page cannot be
99
+ * requested — needs a cursor argument.
100
+ * - S-3 (`CacheableResult` on the five list/read surfaces): needs the four
101
+ * non-tool list/read surfaces, not only `tools/list`.
102
+ * - S-6 (tools/list coherence): needs an emitted `list_changed` notification to
103
+ * observe across an emission, not a single snapshot.
104
+ * - S-5 (OAuth 2.1 resource server) and backend R-6 / R-11: need the same call made
105
+ * under varied credentials / as two principals — `callTool` carries no
106
+ * identity.
107
+ * A rule that needs one of these adds its verb here in one additive edit; the
108
+ * surface stays honest about what it can read today, and nothing is pre-invented
109
+ * for a rule that does not yet read it.
110
+ *
111
+ * Every method is async and MAY reject (connection refused, timeout, protocol
112
+ * error). A `LiveDetector` reading a rejecting session returns `could-not-judge`
113
+ * naming the failure — never a pass, never a violation — exactly as a dynamic
114
+ * detector reads a set `launchError`.
115
+ */
116
+ interface Session {
117
+ /** The served `tools/list` payload (post-startup, as the client receives it). */
118
+ listTools(): Promise<unknown>;
119
+ /** The served discovery / description payload the client reads at connect. */
120
+ discover(): Promise<unknown>;
121
+ /** Invoke one tool and read the served response (or the auth challenge it returns). */
122
+ callTool(name: string, args: unknown): Promise<unknown>;
123
+ }
124
+ /**
125
+ * A detector whose verdict requires CALLING a running server — a live session,
126
+ * not a static tree and not a one-shot snapshot. It is a SEPARATE contract from
127
+ * `Detector`: the async runner routes an object to `runLive` iff it carries one,
128
+ * and to `run(tree)` otherwise. No existing `Detector` is touched or migrated.
129
+ */
130
+ interface LiveDetector {
131
+ id: string;
132
+ /**
133
+ * Live verdict from a running session. REQUIRED for a live detector. It owns
134
+ * the abstain contract: a session it cannot read (absent, throwing, timing
135
+ * out before anything is read) yields `could-not-judge` with a non-empty
136
+ * `reason` — never a pass, never a violation, both directions.
137
+ */
138
+ runLive(session: Session): Promise<DetectorResult>;
139
+ }
140
+ /**
141
+ * Guard: an object is a `LiveDetector` iff it exposes a `runLive` function. The
142
+ * domain of "kinds of detector" is exactly two (tree `run`, live `runLive`);
143
+ * this guard covers it, and the async runner routes on it.
144
+ */
145
+ declare function isLiveDetector(d: Detector | LiveDetector): d is LiveDetector;
10
146
 
11
- declare function runDoctor(target: string): DetectorResult[];
147
+ type RunMode = "static" | "dynamic" | "both";
148
+ interface RunDoctorOptions {
149
+ mode?: RunMode;
150
+ }
151
+ /**
152
+ * Run every detector against the target.
153
+ *
154
+ * Modes (default `static`):
155
+ * - `static` — the default; every detector's `run(tree)`. Behaviour and
156
+ * output are IDENTICAL to before dynamic mode existed.
157
+ * - `dynamic` — the running server is introspected once; a dynamic-capable
158
+ * detector runs `runDynamic(runtime)`, every other detector runs `run(tree)`.
159
+ * - `both` — static for all detectors PLUS dynamic for the dynamic-capable;
160
+ * when a rule's static and dynamic `state` DIFFER, a distinct disagreement
161
+ * result is emitted naming BOTH states and BOTH anchors. The modes never
162
+ * disagree silently.
163
+ *
164
+ * The runner still fails LOUD on its own coverage: a detector that throws aborts
165
+ * the run; a could-not-judge with no reason is refused; every detector produces
166
+ * at least one well-formed result.
167
+ *
168
+ * `detectorList` is injectable for tests; production uses the full registry.
169
+ */
170
+ declare function runDoctor(target: string, detectorList?: Detector[], opts?: RunDoctorOptions): DetectorResult[];
171
+ /**
172
+ * Async runner — the ADDITIVE extension for *dynamique* rules that must CALL a
173
+ * running server. It is a superset of the sync `runDoctor` static path, never a
174
+ * replacement:
175
+ * - a tree `Detector` runs via `run(tree)`, byte-identical to `runDoctor`;
176
+ * - a `LiveDetector` runs via `await runLive(session)`;
177
+ * - a live detector with no session, or whose session throws/times out/refuses
178
+ * before reading anything, yields `could-not-judge` with a non-empty reason
179
+ * (never a pass, never a violation, both directions).
180
+ *
181
+ * The fail-loud discipline is identical: a tree detector that throws aborts the
182
+ * run naming the detector; a could-not-judge with no reason is refused; the
183
+ * coverage-count check asserts exactly one result per detector.
184
+ *
185
+ * The sync `runDoctor`, every registered detector, and `scan.ts` are untouched
186
+ * — a tree-only `runDoctorAsync(target)` returns exactly what `runDoctor(target)`
187
+ * returns.
188
+ */
189
+ declare function runDoctorAsync(target: string, opts?: {
190
+ session?: Session;
191
+ detectorList?: (Detector | LiveDetector)[];
192
+ }): Promise<DetectorResult[]>;
12
193
  declare function renderTable(surface: string, results: DetectorResult[]): string;
194
+ /**
195
+ * The process exit code a run's results carry, three-way with precedence:
196
+ * 1 — a VIOLATION: at least one detector is `non-conforming` (something IS
197
+ * wrong). Takes precedence over an abstention.
198
+ * 2 — an ABSTENTION with zero violations: at least one `could-not-judge`
199
+ * ("I did not look" must NOT reach a caller as a clean 0).
200
+ * 0 — fully judged and conformant: nothing non-conforming, nothing abstained.
201
+ * The rendered table already draws this distinction; this only lets a machine
202
+ * reading the exit code (CI, the foreign-bite probes) see what the table says.
203
+ */
204
+ declare function computeExitCode(results: DetectorResult[]): number;
13
205
 
14
- export { renderTable, runDoctor };
206
+ export { type LiveDetector, type RunDoctorOptions, type RunMode, type Session, computeExitCode, isLiveDetector, renderTable, runDoctor, runDoctorAsync };