@booyaka/mcp-vet 0.3.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/BENCHMARK.md ADDED
@@ -0,0 +1,112 @@
1
+ # Benchmark: corpus, methodology, and honest limits
2
+
3
+ The README claims high precision on real MCP code. This file is the evidence
4
+ behind that claim — corpus, pinned commits, counts, labels, and what the
5
+ scanner is *known to miss* — so the claim is checkable rather than vibes.
6
+
7
+ > Prompted by community feedback on the launch post: *"'0 false positives' is
8
+ > encouraging but incomplete without corpus size, commit SHAs, labeled
9
+ > negatives, and recall."* Correct. Here they are.
10
+
11
+ ## Corpus (pinned)
12
+
13
+ Scanned with `mcp-vet` v0.4.0 (`node dist/cli.js <roots> --json`), all rules
14
+ enabled, default confidence (`low`), on 2026-07-23:
15
+
16
+ | Repo | Commit | Scanned root | Files | LOC |
17
+ | --- | --- | --- | --- | --- |
18
+ | [modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers) | `d31124c982401739917fd817c2a59db344529c16` | `src/` | 78 | 14,742 |
19
+ | [modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk) | `1e1392e3f91583884fe82a0b4b91335875c3fba6` | `examples/` | 144 | 17,224 |
20
+ | [modelcontextprotocol/python-sdk](https://github.com/modelcontextprotocol/python-sdk) | `3a6f2996cdd8358957479791e8b26198c07d6a75` | `examples/` | 225 | 12,013 |
21
+ | **Total** | | | **447** | **43,979** |
22
+
23
+ File counts are candidate files (`.ts/.tsx/.js/.mjs/.cjs/.py`) under the
24
+ scanned roots, excluding `node_modules`.
25
+
26
+ ## Results
27
+
28
+ **105 findings across 41 files** (TypeScript/JavaScript: 93, Python: 12).
29
+ By confidence: 66 high, 38 medium, 1 low.
30
+
31
+ | Pattern | Findings |
32
+ | --- | --- |
33
+ | `MCP_SESSION_ID` | 49 |
34
+ | `LOGGING_CAP` | 17 |
35
+ | `SAMPLING_CAP` | 16 |
36
+ | `ROOTS_CAP` | 15 |
37
+ | `INITIALIZE_HANDLER` | 4 |
38
+ | `TASKS_LEGACY` | 2 |
39
+ | `TASKS_RESULT_REMOVED` | 2 |
40
+
41
+ ### Labeling
42
+
43
+ Every finding was manually reviewed against its source line:
44
+
45
+ - **104 / 105 true positives** — real references to a removed or deprecated
46
+ protocol surface (session headers/ids, handshake registration, legacy task
47
+ methods, deprecated capability declarations and method strings).
48
+ - **1 / 105 false positive (0.95%)** —
49
+ `stories/json_response/client.py:62` in the typescript-sdk examples:
50
+ `assert "mcp-session-id" not in response.headers`. That line is
51
+ *already-migrated* test code asserting the header is **absent**; flagging it
52
+ as "will break" is wrong. It is exactly what inline suppression
53
+ (`# mcp-vet-disable-line MCP_SESSION_ID`) is for, but we count it as a false
54
+ positive rather than defining it away. So the honest headline is
55
+ **"1 false positive in 44k LOC"**, not zero.
56
+
57
+ Notes on reading the numbers:
58
+
59
+ - Two occurrences on one line (e.g. `transport.sessionId && sessions.delete(transport.sessionId)`)
60
+ are reported as two findings — column-level dedup, not line-level.
61
+ - Findings in test files (`__tests__/…`) are counted as true positives: a test
62
+ that registers `sampling/createMessage` breaks the same way production code
63
+ does.
64
+
65
+ ### Labeled negatives
66
+
67
+ Files asserted to stay **clean** are part of the repo's test suite and run in CI:
68
+
69
+ - `test/fixtures/clean/` — a full server written in the 2026-07-28 style
70
+ (per-request `_meta`, `sessionIdGenerator: undefined`, `-32602`).
71
+ - `test/fixtures/negatives/` — "false friend" patterns: `sessionId` on plain
72
+ app-level objects, `-32002` inside strings/comments, capability-like words
73
+ with no capabilities context.
74
+ - `test/fixtures/adversarial/caught/` — obfuscations the scanner **must**
75
+ catch: aliased imports (TS + Python), namespace-qualified SDK constants,
76
+ client transports resuming a `sessionId`.
77
+
78
+ Additionally, in the corpus above, comment-only mentions (e.g. `Mcp-Session-Id`
79
+ in a comment, `initialize` in prose) produced zero findings — the AST layer
80
+ distinguishes executable tokens from comments by construction.
81
+
82
+ ## Recall — what the scanner is known to miss
83
+
84
+ Static token analysis proves known patterns are **absent**; it cannot prove
85
+ your server **speaks the new wire contract**. Recall is bounded by
86
+ construction, and the misses are locked into the test suite
87
+ (`test/fixtures/adversarial/missed/`, asserted to produce zero findings so any
88
+ silent claim-inflation fails CI):
89
+
90
+ - split/computed method strings — `'tasks' + '/list'`, `` `tasks/${op}` ``, f-strings
91
+ - computed capability keys — `{ ['roo'+'ts']: {} }`
92
+ - generated/loop-driven registration from string fragments
93
+ - framework-adapter indirection (route tables built at runtime)
94
+ - cross-module renames — a wrapper re-exporting an SDK constant under a new
95
+ name is flagged in the wrapper file, but a consumer importing only the new
96
+ name scans clean on its own
97
+
98
+ There is no corpus-wide recall *percentage*: that would require a labeled set
99
+ of every legacy usage in the wild, which nobody has. What we can say is: for
100
+ the pattern shapes listed in the README, detection is exact; for the shapes
101
+ above, it is zero, and the tool says so — pair the scan with runtime checks
102
+ (`mcp-vet fixtures`) to cover the difference.
103
+
104
+ ## Reproducing
105
+
106
+ ```bash
107
+ git clone --depth 1 https://github.com/modelcontextprotocol/servers
108
+ git clone --depth 1 https://github.com/modelcontextprotocol/typescript-sdk
109
+ git clone --depth 1 https://github.com/modelcontextprotocol/python-sdk
110
+ # check out the pinned SHAs above, then:
111
+ npx @booyaka/mcp-vet servers/src typescript-sdk/examples python-sdk/examples --json --no-files
112
+ ```
package/CHANGELOG.md CHANGED
@@ -4,6 +4,91 @@ All notable changes to `mcp-vet` are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
5
5
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.0]
8
+
9
+ The runtime-probe release — `mcp-vet probe` connects to a *running* MCP server
10
+ (stdio command or Streamable HTTP URL) and detects the two 2026-07-28 violation
11
+ categories that only exist on the wire, not in source.
12
+
13
+ ### Added
14
+
15
+ - **`mcp-vet probe [options] <url | command...>`** — a runtime prober with two
16
+ new violation categories, reported in the same JSON + SARIF formats as the
17
+ static scan:
18
+ - **`json-schema-dialect` (WARN)** — calls `tools/list` and inspects every
19
+ tool's `inputSchema`/`outputSchema` (SEP-2106 lifts both to full JSON
20
+ Schema 2020-12). Flags an explicit draft-04/-06/-07 (or 2019-09) `$schema`
21
+ at high confidence, and — when `$schema` is absent — draft-only keyword
22
+ forms (`definitions`, `$ref: "#/definitions/…"`, boolean
23
+ `exclusiveMinimum`/`exclusiveMaximum`, array-form `items`, schema-form
24
+ `dependencies`) at medium confidence. The walker recurses only into schema
25
+ positions, so a *property* named `definitions` is never a false positive,
26
+ and an explicit 2020-12 `$schema` is trusted.
27
+ - **`requires-initialize-handshake` (ERROR)** — with
28
+ `--spec-version 2026-07-28`, makes a stateless first request (no
29
+ `initialize`; protocolVersion/clientInfo/capabilities travel in `_meta`
30
+ per the RC) and flags a server that rejects it or hangs. Cross-checked:
31
+ only emitted when the classic 2025-11-25 handshake path *does* work, so a
32
+ dead server is an operational error (exit 2), never a false violation.
33
+ - **`--spec-version <2025-11-25|2026-07-28>`** (default `2025-11-25`) selects
34
+ the revision to vet against; `--timeout <ms>` bounds each request and doubles
35
+ as the hang-detection window; `--json` / `--sarif [file]` / `--fail-on` /
36
+ `--quiet` / `--color` work as in the scan.
37
+ - **Runtime rules in SARIF** — probe rules join the driver metadata when they
38
+ fire (`ERROR` → `error`, `WARN` → `warning`); the static-scan SARIF keeps its
39
+ stable 9-rule shape.
40
+ - **`test/probe-fixtures/`** — minimal real MCP servers used by 18 new tests:
41
+ `server-draft07.mjs` (explicit + inferable draft-07 tools, a modern 2020-12
42
+ tool, and a property literally named `definitions`), `server-requires-init.mjs`
43
+ (rejects pre-initialize requests with `-32002`), `server-stateless.mjs`
44
+ (2026-07-28-native, requires `_meta`, no initialize), and `server-http.mjs`
45
+ (Streamable HTTP, sessionful *and* stateless modes).
46
+ - Verified against the official `@modelcontextprotocol/server-everything@2026.7.4`:
47
+ it answers stateless requests, but all of its tool schemas still declare
48
+ draft-07 — `probe` reports 14 true `json-schema-dialect` findings.
49
+
50
+ ## [0.4.0]
51
+
52
+ The community-feedback release — everything in it traces to reader comments on
53
+ the launch post (issues #1–#6). Static analysis got sharper, and the tool now
54
+ ships the runtime half it was honest about not covering.
55
+
56
+ ### Added
57
+
58
+ - **Client-side session-ownership detection** (#1) — a client transport
59
+ constructed with a real `sessionId`/`session_id` and reads of
60
+ `transport.sessionId` are flagged (`MCP_SESSION_ID`, medium). The migrated
61
+ `sessionId: undefined` / `session_id=None` forms are recognized as benign.
62
+ Servers going stateless is only half the migration; clients that still behave
63
+ as if they own a session break too.
64
+ - **Aliased-import resolution** (#2) — `import { InitializeRequestSchema as Init }`
65
+ (TS) and `from mcp.types import RootsCapability as RC` (Python) now flag both
66
+ the import line and the aliased usage sites. Python import lines surface
67
+ imported names even when only the alias is used later.
68
+ - **Adversarial regression suite** (#3) — `test/fixtures/adversarial/` locks in
69
+ what the scanner catches (`caught/`) *and* what it is known to miss
70
+ (`missed/`, asserted zero findings): computed strings, computed capability
71
+ keys, generated registration, framework adapters, cross-module renames.
72
+ - **`mcp-vet fixtures [dir]`** (#4) — emits nine protocol-level conformance
73
+ fixtures + `CHECKLIST.md`: `server/discover`, per-request `_meta`,
74
+ `Mcp-Method`/`Mcp-Name` routing headers (incl. mismatch rejection), stateless
75
+ auth, task-handle lifecycle, duplicate deliveries, retry on another instance,
76
+ `tools/list` cache invalidation, and downgrade/refusal behavior. Also exported
77
+ programmatically (`CONFORMANCE_FIXTURES`, `emitConformanceFixtures`).
78
+ - **BENCHMARK.md** (#5) — the precision claim is now evidence: pinned corpus
79
+ SHAs, 447 files / ~44k LOC, every finding labeled (105 findings, 104 TP,
80
+ 1 FP), labeled negatives, and an explicit recall discussion.
81
+
82
+ ### Changed
83
+
84
+ - **Docs: spec-date semantics** (#6) — July 28 is a specification release, not
85
+ a remote kill switch; breakage appears when a client/server pair negotiates
86
+ the new revision. README and the post-scan notice now say so, and recommend
87
+ the dual-version (2025-11-25 + 2026-07-28) rollout test matrix.
88
+ - The README "0 false positives" claim is replaced by the measured, reproducible
89
+ numbers in BENCHMARK.md (1 FP in 44k LOC — an already-migrated negative
90
+ assertion in test code).
91
+
7
92
  ## [0.3.0]
8
93
 
9
94
  The completeness release — full detection coverage, a real migration path, and a
package/README.md CHANGED
@@ -15,10 +15,21 @@ npx @booyaka/mcp-vet .
15
15
  ```
16
16
 
17
17
  <p align="center">
18
- <img src="https://raw.githubusercontent.com/Booyaka101/mcp-vet/main/assets/demo.svg" alt="mcp-vet scanning a server — BREAKING and DEPRECATED findings with before/after fixes and confidence tags" width="720">
18
+ <img src="https://raw.githubusercontent.com/Booyaka101/mcp-vet/main/assets/demo.png" alt="mcp-vet scanning a server — BREAKING and DEPRECATED findings with before/after fixes and confidence tags" width="720">
19
19
  </p>
20
20
 
21
- No account, no API key, no network calls — it parses your code locally (ts-morph for TS/JS, a bundled Python `ast` script for `.py`) and exits non-zero if it finds anything **BREAKING**, so you can drop it straight into CI.
21
+ No account, no API key — the scan parses your code locally (ts-morph for TS/JS, a bundled Python `ast` script for `.py`), makes no network calls, and exits non-zero if it finds anything **BREAKING**, so you can drop it straight into CI. (The opt-in [`mcp-vet probe`](#vet-a-running-server-mcp-vet-probe) is the one command that talks to a server — and only the one you point it at.)
22
+
23
+ ## What actually happens on July 28
24
+
25
+ **July 28 is a specification release date, not a switch that remotely disables your deployment.** Nothing reaches into running servers and turns them off. Breakage appears when a **client and server pair negotiates or requires the new revision** — a client that sends `2026-07-28`-style requests (per-request `_meta`, no handshake, routing headers) against a server that still expects `2025-11-25` semantics, or vice versa.
26
+
27
+ Two practical consequences:
28
+
29
+ - **Your rollout is a window, not a day.** Until every client you care about has moved, keep **both** revisions in your production test matrix: a `2025-11-25` path and a `2026-07-28` path. `mcp-vet fixtures` emits wire-level test fixtures for exactly this (see [Runtime conformance fixtures](#runtime-conformance-fixtures)).
30
+ - **Silent acceptance is the worst failure mode.** A server that quietly processes an old-revision request under new semantics (or the reverse) corrupts behavior instead of failing loudly. Verify *refusal* behavior, not just the happy path.
31
+
32
+ The scan tells you *what to change in your source*; the date tells you *when clients start expecting it*.
22
33
 
23
34
  ---
24
35
 
@@ -43,7 +54,7 @@ sse-polling.ts:107:13 BREAKING MCP_SESSION_ID [medium]
43
54
  6 finding(s): 5 BREAKING, 1 DEPRECATED
44
55
  ```
45
56
 
46
- Note it catches the `sessionIdGenerator` session usage — the real signal in SDK-based servers, which usually never write the literal `Mcp-Session-Id` string. And it stays quiet where it should: the `Mcp-Session-Id` mentioned in a *comment*, the `initialize` in a comment in `dual-era.ts`, and the `sampling/createMessage` in `sampling.ts` (which appears only in comments and behind the `requestSampling()` helper) are all left alone. That precision — structural AST checks, not text matching — is what keeps the noise down on a real codebase: **6 findings, 0 false positives.**
57
+ Note it catches the `sessionIdGenerator` session usage — the real signal in SDK-based servers, which usually never write the literal `Mcp-Session-Id` string. And it stays quiet where it should: the `Mcp-Session-Id` mentioned in a *comment*, the `initialize` in a comment in `dual-era.ts`, and the `sampling/createMessage` in `sampling.ts` (which appears only in comments and behind the `requestSampling()` helper) are all left alone. That precision — structural AST checks, not text matching — is what keeps the noise down on a real codebase: **6 findings, 0 false positives on these files.** (Across the full labeled corpus it's 104/105 true positives — see [BENCHMARK.md](./BENCHMARK.md).)
47
58
 
48
59
  ---
49
60
 
@@ -53,7 +64,7 @@ Note it catches the `sessionIdGenerator` session usage — the real signal in SD
53
64
 
54
65
  | ID | Pattern |
55
66
  | --- | --- |
56
- | `MCP_SESSION_ID` | `Mcp-Session-Id` header / `mcpSessionId` variable |
67
+ | `MCP_SESSION_ID` | `Mcp-Session-Id` header / `mcpSessionId` variable / client-side session ownership (`sessionId` passed to or read from a client transport) |
57
68
  | `INITIALIZE_HANDLER` | `initialize` / `notifications/initialized` handler registration |
58
69
  | `ERROR_CODE_32002` | the numeric error code `-32002` |
59
70
  | `TASKS_LEGACY` | `tasks/get` · `tasks/update` · `tasks/cancel` legacy method strings |
@@ -73,7 +84,7 @@ Note it catches the `sessionIdGenerator` session usage — the real signal in SD
73
84
  Every finding carries a **confidence** so you can tune signal-to-noise with `--min-confidence`:
74
85
 
75
86
  - **high** — exact/deterministic match (session id, `-32002`, tasks methods), a structurally-verified capability (the `roots`/`sampling`/`logging` key is really *inside* a `capabilities` object), or an `initialize` string used as a method name (handler registration, `switch` case, or `req.method === 'initialize'`).
76
- - **medium** — a `roots`/`sampling`/`logging` key/string within 5 lines of a `capabilities` mention but not structurally verified.
87
+ - **medium** — a `roots`/`sampling`/`logging` key/string within 5 lines of a `capabilities` mention but not structurally verified; a real `sessionIdGenerator`; client-side session ownership (`sessionId`/`session_id` passed to or read from a transport/client).
77
88
  - **low** — a bare `'initialize'` string with no registration context.
78
89
 
79
90
  ---
@@ -96,6 +107,19 @@ function handle(req) {
96
107
  }
97
108
  ```
98
109
 
110
+ This cuts both ways — **client-side session ownership breaks too**, even against a server that scans clean. A lot of tool-reliability bugs only show up when the server is stateless but the client still behaves as if it owns a session:
111
+
112
+ ```ts
113
+ // ❌ before — the client resumes a stored session
114
+ const transport = new StreamableHTTPClientTransport(url, { sessionId: stored });
115
+ persist(transport.sessionId);
116
+
117
+ // ✅ after — stateless: no stored session id, full _meta on every request
118
+ const transport = new StreamableHTTPClientTransport(url, { sessionId: undefined });
119
+ ```
120
+
121
+ `mcp-vet` flags a client transport constructed with a real `sessionId`/`session_id` and reads of `transport.sessionId` (medium confidence). The migrated `sessionId: undefined` / `session_id=None` forms are recognized and left alone.
122
+
99
123
  ### 2. `initialize` / `notifications/initialized` — the handshake is removed
100
124
 
101
125
  > *"The `initialize`/`initialized` handshake is removed. The protocol version, client info, and client capabilities that used to be exchanged once at connection time now travel in `_meta` on every request."*
@@ -163,10 +187,70 @@ case 'tasks/list': return listTasks();
163
187
  - **The long-lived server→client SSE push channel is removed** — a server may only send requests to the client *while it is actively processing a client request*. Standing push streams / out-of-band notifications need rework.
164
188
  - **Streamable HTTP now requires `Mcp-Method` and `Mcp-Name` headers** that mirror the JSON-RPC body; servers must reject requests where headers and body disagree.
165
189
  - **Auth hardening** — validate the RFC 9207 `iss` parameter, declare OIDC `application_type` on Dynamic Client Registration, and bind tokens to the issuing authorization server.
166
- - **Tool schemas may now be full JSON Schema 2020-12** (`oneOf`/`anyOf`/`$ref`/conditionals); do not auto-dereference external `$ref` URIs.
190
+ - **Tool schemas may now be full JSON Schema 2020-12** (`oneOf`/`anyOf`/`$ref`/conditionals); do not auto-dereference external `$ref` URIs. The *dialect* half of this — schemas still declaring or using draft-07 forms — **is** detectable at runtime: [`mcp-vet probe`](#vet-a-running-server-mcp-vet-probe) checks it against your live server.
167
191
 
168
192
  The CLI prints a one-line reminder of these after every scan.
169
193
 
194
+ ## Runtime conformance fixtures
195
+
196
+ Static analysis proves known legacy patterns are *absent* from your source. Only wire-level tests prove your running server actually *speaks* the 2026-07-28 contract. `mcp-vet` ships both halves:
197
+
198
+ ```bash
199
+ npx @booyaka/mcp-vet fixtures ./mcp-fixtures
200
+ ```
201
+
202
+ writes nine ready-to-fire JSON fixtures plus a `CHECKLIST.md`, covering the runtime behaviors a linter cannot see:
203
+
204
+ 1. `server/discover` replaces the initialize handshake
205
+ 2. per-request `_meta` (protocolVersion, clientInfo, capabilities) — including explicit refusal when `_meta` is missing
206
+ 3. `Mcp-Method` / `Mcp-Name` routing headers, including the header/body-mismatch rejection case
207
+ 4. stateless auth context (no session-bound token cache)
208
+ 5. task-handle lifecycle: creation, `tasks/get` polling, resume on another instance, `tasks/list` and `tasks/result` returning method-not-found
209
+ 6. duplicate request delivery (idempotency under retries)
210
+ 7. retry against a different server instance (no sticky in-memory state)
211
+ 8. `tools/list` cache invalidation
212
+ 9. downgrade/refusal: old-revision requests get an explicit error, never silent acceptance under the wrong semantics
213
+
214
+ Each fixture is a plain JSON description (`send` headers + JSON-RPC body, `expect` notes) you can replay with curl, supertest, pytest + httpx, or any HTTP harness. The checklist also spells out the **dual-version rollout matrix** — run both `2025-11-25` and `2026-07-28` paths until your clients have all moved — and a **client-side assumptions** list (session resume, per-request `_meta`, retries landing on other instances, `tools/list` revalidation).
215
+
216
+ ## Vet a running server (`mcp-vet probe`)
217
+
218
+ Where the scan reads your *source*, `probe` talks to your *running server* over the wire — stdio (a command it spawns) or Streamable HTTP (a URL) — and checks the two 2026-07-28 violations that only exist at runtime:
219
+
220
+ | ID | Severity | What it checks |
221
+ | --- | --- | --- |
222
+ | `json-schema-dialect` | 🟡 WARN | calls `tools/list` and inspects every tool's `inputSchema`/`outputSchema` for a pre-2020-12 JSON Schema dialect ([SEP-2106](https://modelcontextprotocol.io/seps/2106-json-schema-2020-12)) — an explicit draft-04/-06/-07 `$schema` (**high** confidence), or no `$schema` but draft-only keyword forms: `definitions` instead of `$defs`, `$ref: "#/definitions/…"`, boolean `exclusiveMinimum`/`exclusiveMaximum`, array-form `items` (**medium** confidence) |
223
+ | `requires-initialize-handshake` | 🔴 ERROR | with `--spec-version 2026-07-28`: makes a **stateless first request** — no `initialize`, capabilities/clientInfo/protocolVersion in `_meta` per the RC — and flags a server that rejects it or hangs waiting for the removed handshake |
224
+
225
+ ```bash
226
+ # vet the schemas of a stdio server (spawns the command; a lone .js file runs with Node)
227
+ npx @booyaka/mcp-vet probe node ./dist/server.js
228
+
229
+ # full 2026-07-28 readiness: stateless first contact + schema dialects
230
+ npx @booyaka/mcp-vet probe --spec-version 2026-07-28 http://localhost:3000/mcp
231
+ ```
232
+
233
+ ```text
234
+ mcp-vet probe — node ./dist/server.js · spec 2026-07-28 · stdio · 12 tool(s) listed
235
+ stateless probe: stateless tools/list was rejected: -32002 Server not initialized
236
+ fallback probe: initialize handshake + tools/list succeeded
237
+
238
+ ERROR requires-initialize-handshake [high]
239
+ The server rejected (or hung on) a stateless 2026-07-28-style first request ...
240
+ WARN json-schema-dialect [high]
241
+ tool "echo" inputSchema: $schema = http://json-schema.org/draft-07/schema# (draft-07)
242
+ ```
243
+
244
+ The stateless verdict is **cross-checked** before it becomes a violation: `requires-initialize-handshake` is only emitted when the classic `2025-11-25` handshake path *does* work — a dead or non-MCP server is an operational error (exit 2), never a false violation. The dialect walker recurses only into schema positions (applicators like `properties`/`allOf`), so a *property* literally named `definitions` is never mistaken for the draft-07 keyword, and an explicit 2020-12 `$schema` declaration is trusted.
245
+
246
+ Probe findings use the same report formats as the scan: `--json` (machine-readable array on stdout) and `--sarif [file]` (SARIF 2.1.0 — `ERROR` maps to `error`, `WARN` to `warning`), plus `--fail-on breaking|any|none` (default `breaking`: exit 1 only on `ERROR`), `--timeout <ms>` (default 8000, also the hang-detection window), `--quiet`, and `--color`/`--no-color`.
247
+
248
+ Try it against the official reference server — the July 2026 `@modelcontextprotocol/server-everything` answers stateless requests, but its tool schemas still declare draft-07, and `probe` catches all of them:
249
+
250
+ ```bash
251
+ npx @booyaka/mcp-vet probe --spec-version 2026-07-28 npx -y mcp-server-everything stdio
252
+ ```
253
+
170
254
  ## Usage
171
255
 
172
256
  ```bash
@@ -174,6 +258,8 @@ npx @booyaka/mcp-vet [paths...] # scan directories and/or files (default:
174
258
  npx @booyaka/mcp-vet . --fix # scan, and auto-apply the mechanical -32002 → -32602 rewrite
175
259
  npx @booyaka/mcp-vet ./src ./packages # multiple roots
176
260
  npx @booyaka/mcp-vet server.py # a single file
261
+ npx @booyaka/mcp-vet fixtures ./dir # write runtime conformance fixtures + checklist (default: ./mcp-vet-fixtures)
262
+ npx @booyaka/mcp-vet probe <url|cmd> # vet a RUNNING server's wire behavior (see section above)
177
263
  ```
178
264
 
179
265
  Globs `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and `**/*.py`, skipping `node_modules`, `.git`, `__pycache__`, `dist`, and `build`.
@@ -325,15 +411,25 @@ It matches the ways real servers are actually written, not just raw method strin
325
411
  - **SDK schema-constant registration** — `server.setRequestHandler(InitializeRequestSchema, …)` (how the official SDKs register handlers) maps `InitializeRequestSchema`, `ListRootsRequestSchema`, `CreateMessageRequestSchema`, `SetLevelRequestSchema`, `ListTasksRequestSchema`, `GetTaskResultRequestSchema`, … to the right rule.
326
412
  - **SDK capability constructors** — the Python SDK's `ClientCapabilities(roots=RootsCapability())` is recognized structurally (high confidence), and `RootsCapability` / `SamplingCapability` / `LoggingCapability` are matched directly.
327
413
  - **`sessionIdGenerator`** — flagged only when it's a real generator, not the migrated `sessionIdGenerator: undefined`.
414
+ - **aliased imports** — `import { InitializeRequestSchema as Init }` (TS) and `from mcp.types import RootsCapability as RC` (Python) are resolved back to their canonical names, so both the import line and the aliased usage sites are flagged. Namespace access (`types.InitializeRequestSchema`) is matched too.
415
+ - **client-side session ownership** — a client transport constructed with a real `sessionId`/`session_id`, or a read of `transport.sessionId`; the migrated `sessionId: undefined` / `session_id=None` forms are recognized as benign.
328
416
 
329
- Validated against a broad corpus of real MCP servers (the official reference servers, TS + Python): **0 false positives**.
417
+ **Measured, not vibes:** scanned against the official MCP reference servers and both SDK example suites at pinned commits — 447 files / ~44k LOC — every finding manually labeled: **105 findings, 104 true positives, 1 false positive**. Corpus, commit SHAs, per-pattern counts, labeled negatives, and the recall discussion are in [BENCHMARK.md](./BENCHMARK.md).
330
418
 
331
419
  ### Known limitations
332
420
 
421
+ These are locked into the test suite as `test/fixtures/adversarial/missed/` — fixtures asserted to produce **zero** findings, so the claims below can't silently rot in either direction:
422
+
423
+ - **Split/computed method strings** — `"tasks" + "/list"`, `` `tasks/${op}` ``, or `f"tasks/{x}"` are not reconstructed.
424
+ - **Computed capability keys** — `{ ['roo'+'ts']: {} }` never exists as a single token.
425
+ - **Generated/loop-driven registration** — method tables assembled from string fragments at runtime.
426
+ - **Framework-adapter indirection** — routes built dynamically (`app.post('/rpc/' + ns + '/' + action, ...)`).
427
+ - **Cross-module renames** — a wrapper module re-exporting an SDK constant under a new name is flagged *in the wrapper file*, but a consumer importing only the new name scans clean on its own. Scan whole projects, not single files.
333
428
  - **Python SDK decorator/method registration** — a handler wired purely as `@server.list_roots()` or a bare `session.list_roots()` call (with no capability declaration or method string in the file) is not matched, to avoid false positives on generic method names. The capability declaration in the same server is normally caught.
334
- - **Split/computed method strings** — `"tasks" + "/list"` or `f"tasks/{x}"` are not reconstructed.
335
429
  - The **regex fallback** (no Python interpreter) covers only the deterministic rules at reduced precision; install Python for full `.py` fidelity.
336
430
 
431
+ This is the recall boundary of static analysis: it proves known patterns are *absent*, not that the server *speaks the new wire contract*. Cover the difference with the [runtime conformance fixtures](#runtime-conformance-fixtures).
432
+
337
433
  ## Programmatic API
338
434
 
339
435
  The scanner is usable as a library (typed) as well as a CLI — for editor extensions, custom CI steps, or migration harnesses:
@@ -369,10 +465,10 @@ Also exported: `renderJson` / `renderMarkdown` / `renderSarif`, `RULES`, and the
369
465
  ```bash
370
466
  npm install # installs deps and builds (via prepare)
371
467
  npm run build # tsc -> dist/ + copies the Python script
372
- npm test # builds, then runs the Node.js built-in test runner (30 tests)
468
+ npm test # builds, then runs the Node.js built-in test runner (55 tests)
373
469
  ```
374
470
 
375
- Test fixtures live in `test/fixtures/` (dirty TS + Python servers, a `clean/` server with zero violations, `negatives/` true-negatives, a `confidence/` gradient, and `suppress/` cases).
471
+ Test fixtures live in `test/fixtures/` (dirty TS + Python servers, a `clean/` server with zero violations, `negatives/` true-negatives, a `confidence/` gradient, and `suppress/` cases). Runtime-probe fixtures live in `test/probe-fixtures/` — minimal stdio + Streamable-HTTP MCP servers: one returning draft-07 schemas, one requiring the initialize handshake, and one fully stateless 2026-07-28-native.
376
472
 
377
473
  ## License
378
474
 
package/dist/autofix.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Finding, PatternId } from './types';
1
+ import { Finding, ViolationId } from './types';
2
2
  export interface FixPreview {
3
3
  file: string;
4
4
  line: number;
@@ -16,7 +16,7 @@ export interface FixOptions {
16
16
  /** Compute and return the rewrites without touching any files. */
17
17
  dryRun?: boolean;
18
18
  }
19
- export declare function isFixable(id: PatternId): boolean;
19
+ export declare function isFixable(id: ViolationId): boolean;
20
20
  /**
21
21
  * Apply the safe mechanical fixes in place. Returns which findings were fixed so
22
22
  * the caller can drop them from the report and the exit-code calculation.
package/dist/autofix.js CHANGED
@@ -110,9 +110,9 @@ function applyFixes(findings, opts = {}) {
110
110
  preview.push(...localPreview);
111
111
  continue;
112
112
  }
113
- // Only count/return findings as fixed once the write actually succeeds — a
113
+ // Only count/return findings as fixed once the write actually succeeds — a
114
114
  // failed write (read-only file, EACCES) must not report the code as fixed.
115
- const out = (hasBom ? '' : '') + lines.join('\n');
115
+ const out = (hasBom ? '' : '') + lines.join('\n');
116
116
  try {
117
117
  fs.writeFileSync(absPath, out, 'utf8');
118
118
  }