@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 +112 -0
- package/CHANGELOG.md +85 -0
- package/README.md +106 -10
- package/dist/autofix.d.ts +2 -2
- package/dist/autofix.js +2 -2
- package/dist/cli.js +213 -176
- package/dist/conformance.d.ts +24 -0
- package/dist/conformance.js +310 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +4 -2
- package/dist/index.d.ts +11 -4
- package/dist/index.js +15 -1
- package/dist/probe-cli.d.ts +1 -0
- package/dist/probe-cli.js +145 -0
- package/dist/probe.d.ts +33 -0
- package/dist/probe.js +472 -0
- package/dist/python/mcp_ast_scan.py +58 -3
- package/dist/reporters.d.ts +9 -6
- package/dist/reporters.js +57 -4
- package/dist/rules.d.ts +15 -1
- package/dist/rules.js +33 -1
- package/dist/schema-dialect.d.ts +29 -0
- package/dist/schema-dialect.js +115 -0
- package/dist/suppress.d.ts +2 -2
- package/dist/ts-analyzer.js +68 -3
- package/dist/types.d.ts +26 -2
- package/dist/types.js +6 -1
- package/package.json +3 -2
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.
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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:
|
|
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
|
|
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 ? '
|
|
115
|
+
const out = (hasBom ? '' : '') + lines.join('\n');
|
|
116
116
|
try {
|
|
117
117
|
fs.writeFileSync(absPath, out, 'utf8');
|
|
118
118
|
}
|