@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 +79 -0
- package/README.md +4 -0
- package/dist/doctor.d.ts +239 -3
- package/dist/doctor.js +6490 -207
- package/package.json +9 -6
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
|
-
|
|
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
|
-
|
|
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 };
|