@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 +36 -0
- package/README.md +4 -0
- package/dist/doctor.d.ts +195 -3
- package/dist/doctor.js +3730 -101
- package/package.json +10 -2
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
|
-
|
|
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
|
-
|
|
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 };
|