@booyaka/mcp-vet 0.5.0 → 0.7.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 +83 -0
- package/README.md +50 -9
- package/dist/cli.js +8 -4
- package/dist/probe-cli.js +11 -5
- package/dist/probe.d.ts +11 -0
- package/dist/probe.js +365 -40
- package/dist/reporters.js +1 -1
- package/dist/rules.js +65 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,89 @@ 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.7.0]
|
|
8
|
+
|
|
9
|
+
An opt-in `--spec 2026-07-28` compliance suite for `mcp-vet probe` — six
|
|
10
|
+
wire-level checks that run *in addition to* the existing ones, covering the
|
|
11
|
+
stateless-protocol requirements and the three newly-deprecated features. Purely
|
|
12
|
+
additive: no existing check changed, and plain `--spec-version 2026-07-28`
|
|
13
|
+
behaves exactly as before.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`--spec <version>`** on `mcp-vet probe` — a shorthand for `--spec-version`
|
|
18
|
+
that ALSO runs the new compliance suite. `--spec 2026-07-28` vets against the
|
|
19
|
+
2026-07-28 revision *and* adds the six checks below.
|
|
20
|
+
- **`stateless-no-session` (ERROR)** — sends a `tools/list` with no
|
|
21
|
+
`Mcp-Session-Id` and flags a server that rejects it with a session error
|
|
22
|
+
(sessions are removed, SEP-2567).
|
|
23
|
+
- **`stateless-no-init` (ERROR)** — sends a `tools/list` with no
|
|
24
|
+
`initialize`/`initialized` handshake and flags a server that rejects it as
|
|
25
|
+
uninitialized (the handshake is removed, SEP-2575).
|
|
26
|
+
- **`required-headers` (ERROR)** — sends a request carrying the now-required
|
|
27
|
+
`Mcp-Method` / `Mcp-Name` routing headers (Streamable HTTP) and flags a
|
|
28
|
+
server that errors on them; skipped for stdio targets (no request headers).
|
|
29
|
+
- **`deprecated-sampling` (WARN)** — observes a server-initiated
|
|
30
|
+
`sampling/createMessage` request. Sampling is deprecated in 2026-07-28 and
|
|
31
|
+
eligible for removal July 2027; migrate to a direct LLM provider API.
|
|
32
|
+
- **`deprecated-roots` (WARN)** — flags a `roots/list` that returns a result
|
|
33
|
+
(the roots capability is deprecated).
|
|
34
|
+
- **`deprecated-logging` (WARN)** — observes a server-emitted
|
|
35
|
+
`notifications/message` (the MCP logging protocol is deprecated; migrate to
|
|
36
|
+
stderr or OpenTelemetry).
|
|
37
|
+
- **`test/probe-fixtures/server-sessionful.mjs`** and
|
|
38
|
+
**`server-deprecated.mjs`** — new stdio fixtures isolating a session
|
|
39
|
+
requirement and the three deprecated features; 9 new tests (71 total),
|
|
40
|
+
including proof that the suite is gated behind `--spec` (plain
|
|
41
|
+
`--spec-version 2026-07-28` never runs it) and that a migrated server passes
|
|
42
|
+
every new check.
|
|
43
|
+
|
|
44
|
+
### How it works
|
|
45
|
+
|
|
46
|
+
The suite runs on its own fresh connection(s) after the existing probe path
|
|
47
|
+
completes, so nothing above it changed. The prober's stdio/HTTP transports gain
|
|
48
|
+
an optional server-message observer (used to catch the deprecated
|
|
49
|
+
`sampling/createMessage` and `notifications/message` traffic) and per-request
|
|
50
|
+
header support (used to send `Mcp-Method`/`Mcp-Name`).
|
|
51
|
+
|
|
52
|
+
## [0.6.0]
|
|
53
|
+
|
|
54
|
+
The full 2026-07-28 compliance suite — `mcp-vet probe --spec-version 2026-07-28`
|
|
55
|
+
now covers all three breaking changes the release candidate makes to server
|
|
56
|
+
behavior, not just the removed handshake.
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **`missing-server-discover` (ERROR, `--spec-version 2026-07-28`)** — calls
|
|
61
|
+
the `server/discover` RPC that every 2026-07-28 server MUST implement
|
|
62
|
+
(SEP-2575; it replaces the removed initialize handshake for up-front
|
|
63
|
+
capability discovery) and flags a server whose answer is an error, a hang, or
|
|
64
|
+
a result missing the required `capabilities` key. The spec defines
|
|
65
|
+
`server/discover` as JSON-RPC only — 2026-07-28 removes the HTTP GET
|
|
66
|
+
endpoint, so no `GET /mcp/discover` variant is probed.
|
|
67
|
+
- **`legacy-resource-error-code` (ERROR, `--spec-version 2026-07-28`)** — reads
|
|
68
|
+
a deliberately nonexistent resource URI (`mcp-vet://probe/...`) and flags a
|
|
69
|
+
server that still answers `-32002` instead of the JSON-RPC standard `-32602`
|
|
70
|
+
(Invalid Params). Servers without `resources/read` (`-32601`) are skipped,
|
|
71
|
+
not flagged; unexpected codes are reported as inconclusive notes.
|
|
72
|
+
- **`mcp-vet run`** — an alias for `mcp-vet probe`.
|
|
73
|
+
- **`test/probe-fixtures/server-partial.mjs`** — a hybrid (handshake +
|
|
74
|
+
stateless) fixture with exactly one migration defect per mode
|
|
75
|
+
(`legacy-error-code` / `no-discover` / `bad-discover`), isolating each new
|
|
76
|
+
rule; 7 new tests (62 total), including proof that the new checks are gated
|
|
77
|
+
behind `--spec-version 2026-07-28` and the default probe is unchanged.
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
|
|
81
|
+
- The stateless first request now sends the RC's exact namespaced `_meta` key
|
|
82
|
+
`io.modelcontextprotocol/clientCapabilities` (was the incorrect
|
|
83
|
+
`io.modelcontextprotocol/capabilities`); the stateless fixtures now *require*
|
|
84
|
+
the namespaced keys, locking the wire format into the tests.
|
|
85
|
+
- The new checks run on whichever contact path succeeded (stateless or classic
|
|
86
|
+
fallback), so a handshake-only legacy server gets its complete 2026-07-28
|
|
87
|
+
migration report — handshake + discover findings — in a single probe.
|
|
88
|
+
- `ProbeResult` gains `discoverOk` and `errorCodeOk` verdict fields.
|
|
89
|
+
|
|
7
90
|
## [0.5.0]
|
|
8
91
|
|
|
9
92
|
The runtime-probe release — `mcp-vet probe` connects to a *running* MCP server
|
package/README.md
CHANGED
|
@@ -215,18 +215,21 @@ Each fixture is a plain JSON description (`send` headers + JSON-RPC body, `expec
|
|
|
215
215
|
|
|
216
216
|
## Vet a running server (`mcp-vet probe`)
|
|
217
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
|
|
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 2026-07-28 violations that only exist at runtime (`run` is an alias: `mcp-vet run …` ≡ `mcp-vet probe …`):
|
|
219
219
|
|
|
220
220
|
| ID | Severity | What it checks |
|
|
221
221
|
| --- | --- | --- |
|
|
222
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`,
|
|
223
|
+
| `requires-initialize-handshake` | 🔴 ERROR | with `--spec-version 2026-07-28`: makes a **stateless first request** — no `initialize`, protocolVersion/clientInfo/clientCapabilities in namespaced `_meta` keys per the RC — and flags a server that rejects it or hangs waiting for the removed handshake. A valid `tools` array in the answer is asserted, not just a 200 |
|
|
224
|
+
| `missing-server-discover` | 🔴 ERROR | with `--spec-version 2026-07-28`: calls the **`server/discover`** RPC that every 2026-07-28 server MUST implement ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575) — it replaces the handshake for up-front capability discovery) and flags a server whose answer is an error or lacks the required `capabilities` key. (The spec defines `server/discover` as JSON-RPC only — 2026-07-28 *removes* the HTTP GET endpoint, so there is no `GET /mcp/discover` to fall back to) |
|
|
225
|
+
| `legacy-resource-error-code` | 🔴 ERROR | with `--spec-version 2026-07-28`: reads a deliberately nonexistent resource URI and flags a server that still answers with the MCP-custom **`-32002`** instead of the JSON-RPC standard **`-32602`** (Invalid Params). Servers without `resources/read` (`-32601`) are skipped, not flagged |
|
|
224
226
|
|
|
225
227
|
```bash
|
|
226
228
|
# vet the schemas of a stdio server (spawns the command; a lone .js file runs with Node)
|
|
227
229
|
npx @booyaka/mcp-vet probe node ./dist/server.js
|
|
228
230
|
|
|
229
|
-
# full 2026-07-28 readiness: stateless first contact +
|
|
231
|
+
# full 2026-07-28 readiness: stateless first contact + server/discover +
|
|
232
|
+
# resource error code + schema dialects
|
|
230
233
|
npx @booyaka/mcp-vet probe --spec-version 2026-07-28 http://localhost:3000/mcp
|
|
231
234
|
```
|
|
232
235
|
|
|
@@ -234,21 +237,59 @@ npx @booyaka/mcp-vet probe --spec-version 2026-07-28 http://localhost:3000/mcp
|
|
|
234
237
|
mcp-vet probe — node ./dist/server.js · spec 2026-07-28 · stdio · 12 tool(s) listed
|
|
235
238
|
stateless probe: stateless tools/list was rejected: -32002 Server not initialized
|
|
236
239
|
fallback probe: initialize handshake + tools/list succeeded
|
|
240
|
+
server/discover: rejected (-32601)
|
|
241
|
+
resource error-code check skipped — server does not implement resources/read (-32601)
|
|
237
242
|
|
|
238
243
|
ERROR requires-initialize-handshake [high]
|
|
239
244
|
The server rejected (or hung on) a stateless 2026-07-28-style first request ...
|
|
245
|
+
ERROR missing-server-discover [high]
|
|
246
|
+
The 2026-07-28 spec requires every server to implement the server/discover RPC ...
|
|
240
247
|
WARN json-schema-dialect [high]
|
|
241
248
|
tool "echo" inputSchema: $schema = http://json-schema.org/draft-07/schema# (draft-07)
|
|
242
249
|
```
|
|
243
250
|
|
|
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.
|
|
251
|
+
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 `server/discover` and error-code checks then run on whichever contact path succeeded, so even a handshake-only server gets its complete migration report in one probe. 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.
|
|
252
|
+
|
|
253
|
+
### `--spec-version` — which revision to vet against
|
|
254
|
+
|
|
255
|
+
| Value | Behavior |
|
|
256
|
+
| --- | --- |
|
|
257
|
+
| `2025-11-25` *(default)* | today's stable contract: classic `initialize` handshake, then the `json-schema-dialect` check. **No 2026-07-28 assertions run** — a fully 2025-era server probes clean |
|
|
258
|
+
| `2026-07-28` | the full new-spec compliance suite: stateless first contact, required `server/discover`, `-32602` resource error code, plus the dialect check |
|
|
259
|
+
|
|
260
|
+
**Migration note.** The default stays `2025-11-25` so existing CI invocations keep their exact behavior — add the flag when *you* are ready, not when the spec ships. A practical rollout:
|
|
261
|
+
|
|
262
|
+
1. Today: `mcp-vet probe <server>` (unchanged) plus the static scan in CI.
|
|
263
|
+
2. When you start migrating: add a second CI job with `--spec-version 2026-07-28 --fail-on none` to *see* the new-spec violations without failing the build.
|
|
264
|
+
3. When your server targets `2026-07-28` (e.g. after moving to `@modelcontextprotocol/server` 2.x): drop `--fail-on none` so the three ERROR-level checks gate the build. A correctly migrated server passes all of them; the pre-migration server fails `requires-initialize-handshake` and `missing-server-discover` immediately.
|
|
265
|
+
4. Keep a `2025-11-25` probe in the matrix until every client you serve has moved (the rollout is a window, not a day — see [What actually happens on July 28](#what-actually-happens-on-july-28)).
|
|
266
|
+
|
|
267
|
+
### `--spec 2026-07-28` — the extra compliance suite
|
|
268
|
+
|
|
269
|
+
`--spec` is a shorthand for `--spec-version` that **also** runs six additional wire-level checks *on top of* the ones above. `--spec 2026-07-28` vets against the new revision **and** adds the suite; plain `--spec-version 2026-07-28` is unchanged and never runs it, so existing CI invocations keep their exact behavior.
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
# full readiness AND the extra compliance suite
|
|
273
|
+
npx @booyaka/mcp-vet probe --spec 2026-07-28 node ./dist/server.js
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
| ID | Severity | What it checks |
|
|
277
|
+
| --- | --- | --- |
|
|
278
|
+
| `stateless-no-session` | 🔴 ERROR | sends `tools/list` with **no** `Mcp-Session-Id` and flags a server that rejects it with a session error — sessions are removed on 2026-07-28 (SEP-2567), so a stateless request must be served |
|
|
279
|
+
| `stateless-no-init` | 🔴 ERROR | sends `tools/list` with **no** `initialize`/`initialized` handshake and flags a server that rejects it as uninitialized — the handshake is removed (SEP-2575); a compliant server answers the first request directly |
|
|
280
|
+
| `required-headers` | 🔴 ERROR | sends a request carrying the now-required `Mcp-Method` / `Mcp-Name` routing headers and flags a server that errors on them. Skipped for **stdio** targets (there are no request headers over stdio) |
|
|
281
|
+
| `deprecated-sampling` | 🟡 WARN | observes a server-initiated `sampling/createMessage` request. Sampling is deprecated in 2026-07-28 and **eligible for removal July 2027** — migrate to a direct LLM provider API |
|
|
282
|
+
| `deprecated-roots` | 🟡 WARN | flags a `roots/list` that returns a result — the roots capability is deprecated |
|
|
283
|
+
| `deprecated-logging` | 🟡 WARN | observes a server-emitted `notifications/message` — the MCP logging protocol is deprecated; migrate to stderr (stdio) or OpenTelemetry |
|
|
284
|
+
|
|
285
|
+
The two `stateless-*` checks are cross-checked the same way the rest of the probe is: a server that answers a stateless, session-less, handshake-less `tools/list` passes both; one that rejects it is classified by *why* — a `session` error trips `stateless-no-session`, an `uninitialized` error trips `stateless-no-init` (a session rejection trips both, since a sessionful server is also not answering the first request directly). The two `deprecated-sampling` / `deprecated-logging` checks watch for server→client traffic for a short window (up to the spec's 5 s, bounded by `--timeout`) and report only what the server actually sends — a server that never samples or logs stays clean. The suite runs on its own fresh connection after the standard probe completes, so the ERROR checks above are unaffected.
|
|
245
286
|
|
|
246
287
|
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
288
|
|
|
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
|
|
289
|
+
Try it against the official reference server — the July 2026 `@modelcontextprotocol/server-everything` (beta 2026-07-28 SDK) answers stateless requests and already returns the new `-32602` resource error code, but it does not implement `server/discover` yet and its tool schemas still declare draft-07 — `probe` reports exactly that (1 ERROR, 13 WARN):
|
|
249
290
|
|
|
250
291
|
```bash
|
|
251
|
-
npx @booyaka/mcp-vet probe --spec-version 2026-07-28 npx -y
|
|
292
|
+
npx @booyaka/mcp-vet probe --spec-version 2026-07-28 npx -y @modelcontextprotocol/server-everything stdio
|
|
252
293
|
```
|
|
253
294
|
|
|
254
295
|
## Usage
|
|
@@ -259,7 +300,7 @@ npx @booyaka/mcp-vet . --fix # scan, and auto-apply the mechanical -32
|
|
|
259
300
|
npx @booyaka/mcp-vet ./src ./packages # multiple roots
|
|
260
301
|
npx @booyaka/mcp-vet server.py # a single file
|
|
261
302
|
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)
|
|
303
|
+
npx @booyaka/mcp-vet probe <url|cmd> # vet a RUNNING server's wire behavior (see section above; alias: run)
|
|
263
304
|
```
|
|
264
305
|
|
|
265
306
|
Globs `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and `**/*.py`, skipping `node_modules`, `.git`, `__pycache__`, `dist`, and `build`.
|
|
@@ -465,10 +506,10 @@ Also exported: `renderJson` / `renderMarkdown` / `renderSarif`, `RULES`, and the
|
|
|
465
506
|
```bash
|
|
466
507
|
npm install # installs deps and builds (via prepare)
|
|
467
508
|
npm run build # tsc -> dist/ + copies the Python script
|
|
468
|
-
npm test # builds, then runs the Node.js built-in test runner (
|
|
509
|
+
npm test # builds, then runs the Node.js built-in test runner (62 tests)
|
|
469
510
|
```
|
|
470
511
|
|
|
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,
|
|
512
|
+
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, one fully migrated 2026-07-28-native (stateless + `server/discover` + `-32602`), and a `server-partial.mjs` with one deliberate migration defect per mode (`legacy-error-code` / `no-discover` / `bad-discover`).
|
|
472
513
|
|
|
473
514
|
## License
|
|
474
515
|
|
package/dist/cli.js
CHANGED
|
@@ -99,9 +99,10 @@ if (process.argv[2] === 'fixtures') {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
// `mcp-vet probe [options] <url | command...>` — connect to a RUNNING server and
|
|
102
|
-
// vet its wire behavior (JSON Schema dialect, stateless-protocol readiness
|
|
102
|
+
// vet its wire behavior (JSON Schema dialect, stateless-protocol readiness,
|
|
103
|
+
// server/discover, resource error code). `run` is an alias for `probe`.
|
|
103
104
|
// Async, so the scan pipeline only runs in the else-branch.
|
|
104
|
-
if (process.argv[2] === 'probe') {
|
|
105
|
+
if (process.argv[2] === 'probe' || process.argv[2] === 'run') {
|
|
105
106
|
(0, probe_cli_1.runProbeCli)(process.argv.slice(3)).then((code) => process.exit(code), (err) => fail(err.message));
|
|
106
107
|
}
|
|
107
108
|
else {
|
|
@@ -139,8 +140,11 @@ function scanMain() {
|
|
|
139
140
|
'',
|
|
140
141
|
'Commands:',
|
|
141
142
|
' fixtures [dir] write protocol-level conformance fixtures + CHECKLIST.md (default: ./mcp-vet-fixtures)',
|
|
142
|
-
' probe [options] <url|command> connect to a RUNNING server and vet its wire behavior',
|
|
143
|
-
' (JSON Schema 2020-12 dialect; with --spec-version 2026-07-28, stateless
|
|
143
|
+
' probe [options] <url|command> connect to a RUNNING server and vet its wire behavior (alias: run)',
|
|
144
|
+
' (JSON Schema 2020-12 dialect; with --spec-version 2026-07-28, also stateless',
|
|
145
|
+
' readiness, the required server/discover RPC, and the -32602 resource error code;',
|
|
146
|
+
' with --spec 2026-07-28, ALSO the compliance suite: stateless-no-session,',
|
|
147
|
+
' stateless-no-init, required-headers, deprecated-sampling/roots/logging)',
|
|
144
148
|
].join('\n'))
|
|
145
149
|
.showHelpAfterError();
|
|
146
150
|
program.parse(process.argv);
|
package/dist/probe-cli.js
CHANGED
|
@@ -58,9 +58,10 @@ async function runProbeCli(argv) {
|
|
|
58
58
|
const program = new commander_1.Command();
|
|
59
59
|
program
|
|
60
60
|
.name('mcp-vet probe')
|
|
61
|
-
.description('Connect to a RUNNING MCP server (stdio command or Streamable HTTP URL) and vet its wire behavior: JSON Schema dialect of tool schemas (SEP-2106) and, with --spec-version 2026-07-28, stateless-protocol readiness.')
|
|
61
|
+
.description('Connect to a RUNNING MCP server (stdio command or Streamable HTTP URL) and vet its wire behavior: JSON Schema dialect of tool schemas (SEP-2106) and, with --spec-version 2026-07-28, stateless-protocol readiness, the required server/discover RPC (SEP-2575), and the -32002 → -32602 resource error-code change. Add --spec 2026-07-28 to ALSO run the compliance suite: stateless-no-session, stateless-no-init, required-headers, and the deprecated-sampling/roots/logging warnings.')
|
|
62
62
|
.argument('<target...>', 'server URL (http/https) or a command + args to spawn (stdio)')
|
|
63
63
|
.option('--spec-version <version>', `MCP revision to vet against: ${types_1.SPEC_VERSIONS.join(' | ')}`, '2025-11-25')
|
|
64
|
+
.option('--spec <version>', `shorthand for --spec-version that ALSO runs the extra compliance suite (stateless-no-session, stateless-no-init, required-headers, deprecated-sampling/roots/logging): ${types_1.SPEC_VERSIONS.join(' | ')}`)
|
|
64
65
|
.option('--timeout <ms>', 'per-request timeout in milliseconds', '8000')
|
|
65
66
|
.option('--fail-on <level>', `exit non-zero on: ${FAILON_VALUES.join(' | ')}`, 'breaking')
|
|
66
67
|
.option('--json', 'print findings as a JSON array to stdout (notices go to stderr)')
|
|
@@ -80,10 +81,15 @@ async function runProbeCli(argv) {
|
|
|
80
81
|
return code === 'commander.helpDisplayed' || code === 'commander.help' ? 0 : 2;
|
|
81
82
|
}
|
|
82
83
|
const opts = program.opts();
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
// `--spec` is a shorthand for `--spec-version` that ALSO enables the extra
|
|
85
|
+
// compliance suite. When present it wins over --spec-version.
|
|
86
|
+
const specChecks = opts.spec !== undefined;
|
|
87
|
+
const rawSpec = opts.spec ?? opts.specVersion;
|
|
88
|
+
if (!types_1.SPEC_VERSIONS.includes(rawSpec)) {
|
|
89
|
+
const flag = opts.spec !== undefined ? '--spec' : '--spec-version';
|
|
90
|
+
fail(`invalid ${flag} "${rawSpec}". Valid: ${types_1.SPEC_VERSIONS.join(', ')}`);
|
|
85
91
|
}
|
|
86
|
-
const specVersion =
|
|
92
|
+
const specVersion = rawSpec;
|
|
87
93
|
const timeoutMs = Number(opts.timeout);
|
|
88
94
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
89
95
|
fail(`invalid --timeout "${opts.timeout}" (need a positive number of milliseconds).`);
|
|
@@ -111,7 +117,7 @@ async function runProbeCli(argv) {
|
|
|
111
117
|
}
|
|
112
118
|
let result;
|
|
113
119
|
try {
|
|
114
|
-
result = await (0, probe_1.probeServer)(target, { specVersion, timeoutMs });
|
|
120
|
+
result = await (0, probe_1.probeServer)(target, { specVersion, timeoutMs, specChecks });
|
|
115
121
|
}
|
|
116
122
|
catch (err) {
|
|
117
123
|
if (err instanceof probe_1.ProbeError)
|
package/dist/probe.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export interface ProbeOptions {
|
|
|
14
14
|
specVersion: SpecVersion;
|
|
15
15
|
/** per-request timeout in ms (also the stateless hang-detection window) */
|
|
16
16
|
timeoutMs: number;
|
|
17
|
+
/**
|
|
18
|
+
* Run the `--spec 2026-07-28` compliance suite in addition to the existing
|
|
19
|
+
* checks: stateless-no-session, stateless-no-init, required-headers, and the
|
|
20
|
+
* deprecated-sampling / deprecated-roots / deprecated-logging warnings. Only
|
|
21
|
+
* meaningful together with specVersion '2026-07-28'.
|
|
22
|
+
*/
|
|
23
|
+
specChecks?: boolean;
|
|
17
24
|
}
|
|
18
25
|
export interface ProbeResult {
|
|
19
26
|
/** human-readable target (the command line or the URL) */
|
|
@@ -27,6 +34,10 @@ export interface ProbeResult {
|
|
|
27
34
|
statelessOk: boolean | null;
|
|
28
35
|
/** classic initialize-handshake verdict; null = not attempted */
|
|
29
36
|
handshakeOk: boolean | null;
|
|
37
|
+
/** server/discover verdict; null = not probed (spec 2025-11-25) */
|
|
38
|
+
discoverOk: boolean | null;
|
|
39
|
+
/** nonexistent-resource error-code verdict; null = not probed or inconclusive */
|
|
40
|
+
errorCodeOk: boolean | null;
|
|
30
41
|
notes: string[];
|
|
31
42
|
}
|
|
32
43
|
export declare function targetLabel(target: ProbeTarget): string;
|
package/dist/probe.js
CHANGED
|
@@ -46,10 +46,19 @@ exports.probeServer = probeServer;
|
|
|
46
46
|
* 2026-07-28) — makes a stateless first request (no initialize; capabilities
|
|
47
47
|
* travel in _meta per the 2026-07-28 RC). A server that rejects or hangs on
|
|
48
48
|
* it still requires the removed handshake.
|
|
49
|
+
* 3. `missing-server-discover` (ERROR, 2026-07-28 only) — calls the required
|
|
50
|
+
* server/discover RPC (SEP-2575) and expects a result with a `capabilities`
|
|
51
|
+
* key. There is no HTTP GET variant — 2026-07-28 removes the GET endpoint.
|
|
52
|
+
* 4. `legacy-resource-error-code` (ERROR, 2026-07-28 only) — reads a
|
|
53
|
+
* deliberately nonexistent resource URI and flags a server that still
|
|
54
|
+
* answers with the removed -32002 code instead of -32602 (Invalid Params).
|
|
55
|
+
* Servers without resources support (-32601) are skipped, not flagged.
|
|
49
56
|
*
|
|
50
57
|
* The stateless verdict is cross-checked: the violation is only emitted when
|
|
51
58
|
* the classic 2025-11-25 handshake path *does* work, so a dead/broken server is
|
|
52
|
-
* reported as an operational error (exit 2), not a false violation.
|
|
59
|
+
* reported as an operational error (exit 2), not a false violation. Checks 3-4
|
|
60
|
+
* run on whichever contact path succeeded, so a legacy server gets a complete
|
|
61
|
+
* migration report in one probe.
|
|
53
62
|
*/
|
|
54
63
|
const node_child_process_1 = require("node:child_process");
|
|
55
64
|
const fs = __importStar(require("node:fs"));
|
|
@@ -106,6 +115,7 @@ function openStdio(rawCommand, args) {
|
|
|
106
115
|
let closedReason = '';
|
|
107
116
|
const stderrTail = [];
|
|
108
117
|
const pending = new Map();
|
|
118
|
+
const serverListeners = new Set();
|
|
109
119
|
const failAll = (reason) => {
|
|
110
120
|
closed = true;
|
|
111
121
|
closedReason = reason;
|
|
@@ -133,6 +143,12 @@ function openStdio(rawCommand, args) {
|
|
|
133
143
|
pending.delete(msg.id);
|
|
134
144
|
p.resolve(msg);
|
|
135
145
|
}
|
|
146
|
+
else if (msg && typeof msg === 'object' && typeof msg.method === 'string') {
|
|
147
|
+
// A server-initiated request or notification (its id, if any, is not one
|
|
148
|
+
// we issued) — surface it to any deprecated-traffic observers.
|
|
149
|
+
for (const l of serverListeners)
|
|
150
|
+
l(msg);
|
|
151
|
+
}
|
|
136
152
|
}
|
|
137
153
|
});
|
|
138
154
|
child.stderr.on('data', (d) => {
|
|
@@ -147,7 +163,8 @@ function openStdio(rawCommand, args) {
|
|
|
147
163
|
(tail ? ` — stderr: ${tail}` : ''));
|
|
148
164
|
});
|
|
149
165
|
return {
|
|
150
|
-
|
|
166
|
+
// extraHeaders is a Streamable-HTTP concept; stdio has no request headers.
|
|
167
|
+
request(method, params, timeoutMs, _extraHeaders) {
|
|
151
168
|
return new Promise((resolve, reject) => {
|
|
152
169
|
if (closed)
|
|
153
170
|
return reject(new ConnectionClosedError(closedReason));
|
|
@@ -186,6 +203,10 @@ function openStdio(rawCommand, args) {
|
|
|
186
203
|
/* connection already down — the next request reports it */
|
|
187
204
|
}
|
|
188
205
|
},
|
|
206
|
+
onServerMessage(listener) {
|
|
207
|
+
serverListeners.add(listener);
|
|
208
|
+
return () => serverListeners.delete(listener);
|
|
209
|
+
},
|
|
189
210
|
close() {
|
|
190
211
|
closed = true;
|
|
191
212
|
try {
|
|
@@ -218,7 +239,16 @@ function parseSse(body, id) {
|
|
|
218
239
|
function openHttp(url, specVersion) {
|
|
219
240
|
let nextId = 1;
|
|
220
241
|
let sessionId;
|
|
221
|
-
const
|
|
242
|
+
const serverListeners = new Set();
|
|
243
|
+
// Feed any server-initiated (method-bearing) message from a response body to
|
|
244
|
+
// the deprecated-traffic observers, ignoring replies to our own request id.
|
|
245
|
+
const dispatchServer = (msg, ourId) => {
|
|
246
|
+
if (msg && typeof msg === 'object' && typeof msg.method === 'string' && msg.id !== ourId) {
|
|
247
|
+
for (const l of serverListeners)
|
|
248
|
+
l(msg);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const post = async (payload, method, timeoutMs, extraHeaders) => {
|
|
222
252
|
const headers = {
|
|
223
253
|
'content-type': 'application/json',
|
|
224
254
|
accept: 'application/json, text/event-stream',
|
|
@@ -229,6 +259,9 @@ function openHttp(url, specVersion) {
|
|
|
229
259
|
// mirrors the JSON-RPC body.
|
|
230
260
|
if (specVersion === '2026-07-28')
|
|
231
261
|
headers['mcp-method'] = method;
|
|
262
|
+
if (extraHeaders)
|
|
263
|
+
for (const [k, v] of Object.entries(extraHeaders))
|
|
264
|
+
headers[k.toLowerCase()] = v;
|
|
232
265
|
let res;
|
|
233
266
|
try {
|
|
234
267
|
res = await fetch(url, {
|
|
@@ -251,19 +284,38 @@ function openHttp(url, specVersion) {
|
|
|
251
284
|
return res;
|
|
252
285
|
};
|
|
253
286
|
return {
|
|
254
|
-
async request(method, params, timeoutMs) {
|
|
287
|
+
async request(method, params, timeoutMs, extraHeaders) {
|
|
255
288
|
const id = nextId++;
|
|
256
|
-
const res = await post({ jsonrpc: '2.0', id, method, params }, method, timeoutMs);
|
|
289
|
+
const res = await post({ jsonrpc: '2.0', id, method, params }, method, timeoutMs, extraHeaders);
|
|
257
290
|
const body = await res.text();
|
|
258
291
|
const ct = res.headers.get('content-type') ?? '';
|
|
259
292
|
let msg = null;
|
|
260
293
|
if (ct.includes('text/event-stream')) {
|
|
294
|
+
// A single POST may carry server→client traffic (e.g. a sampling
|
|
295
|
+
// request) alongside our reply in the SSE stream — surface it.
|
|
296
|
+
for (const line of body.split(/\r?\n/)) {
|
|
297
|
+
if (!line.startsWith('data:'))
|
|
298
|
+
continue;
|
|
299
|
+
try {
|
|
300
|
+
dispatchServer(JSON.parse(line.slice(5).trim()), id);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
/* not a JSON data line */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
261
306
|
msg = parseSse(body, id);
|
|
262
307
|
}
|
|
263
308
|
else {
|
|
264
309
|
try {
|
|
265
310
|
const parsed = JSON.parse(body);
|
|
266
|
-
|
|
311
|
+
if (Array.isArray(parsed)) {
|
|
312
|
+
for (const m of parsed)
|
|
313
|
+
dispatchServer(m, id);
|
|
314
|
+
msg = parsed.find((m) => m && m.id === id) ?? null;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
msg = parsed;
|
|
318
|
+
}
|
|
267
319
|
}
|
|
268
320
|
catch {
|
|
269
321
|
msg = null;
|
|
@@ -289,6 +341,10 @@ function openHttp(url, specVersion) {
|
|
|
289
341
|
/* notifications are best-effort */
|
|
290
342
|
}
|
|
291
343
|
},
|
|
344
|
+
onServerMessage(listener) {
|
|
345
|
+
serverListeners.add(listener);
|
|
346
|
+
return () => serverListeners.delete(listener);
|
|
347
|
+
},
|
|
292
348
|
close() {
|
|
293
349
|
/* nothing persistent to tear down */
|
|
294
350
|
},
|
|
@@ -305,12 +361,12 @@ function openConnection(target, specVersion) {
|
|
|
305
361
|
function clientInfo() {
|
|
306
362
|
return { name: 'mcp-vet', version: (0, constants_1.getVersion)() };
|
|
307
363
|
}
|
|
308
|
-
/** _meta for a stateless 2026-07-28
|
|
364
|
+
/** _meta for a stateless 2026-07-28 request (per the RC's namespaced keys). */
|
|
309
365
|
function statelessMeta() {
|
|
310
366
|
return {
|
|
311
367
|
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
|
312
368
|
'io.modelcontextprotocol/clientInfo': clientInfo(),
|
|
313
|
-
'io.modelcontextprotocol/
|
|
369
|
+
'io.modelcontextprotocol/clientCapabilities': {},
|
|
314
370
|
};
|
|
315
371
|
}
|
|
316
372
|
/** Stateless 2026-07-28 first contact: tools/list with _meta, NO initialize. */
|
|
@@ -319,6 +375,7 @@ async function tryStateless(target, opts) {
|
|
|
319
375
|
try {
|
|
320
376
|
const resp = await conn.request('tools/list', { _meta: statelessMeta() }, opts.timeoutMs);
|
|
321
377
|
if (resp.error) {
|
|
378
|
+
conn.close();
|
|
322
379
|
return {
|
|
323
380
|
ok: false,
|
|
324
381
|
evidence: `stateless tools/list was rejected: ${resp.error.code ?? '?'} ${resp.error.message ?? ''}`.trim(),
|
|
@@ -326,11 +383,13 @@ async function tryStateless(target, opts) {
|
|
|
326
383
|
}
|
|
327
384
|
const tools = resp.result?.tools;
|
|
328
385
|
if (!Array.isArray(tools)) {
|
|
386
|
+
conn.close();
|
|
329
387
|
return { ok: false, evidence: 'stateless tools/list answered without a tools array' };
|
|
330
388
|
}
|
|
331
|
-
return { ok: true, tools, evidence: 'server answered a stateless tools/list (no initialize)' };
|
|
389
|
+
return { ok: true, tools, conn, evidence: 'server answered a stateless tools/list (no initialize)' };
|
|
332
390
|
}
|
|
333
391
|
catch (err) {
|
|
392
|
+
conn.close();
|
|
334
393
|
if (err instanceof TimeoutError) {
|
|
335
394
|
return {
|
|
336
395
|
ok: false,
|
|
@@ -339,9 +398,6 @@ async function tryStateless(target, opts) {
|
|
|
339
398
|
}
|
|
340
399
|
return { ok: false, evidence: err.message };
|
|
341
400
|
}
|
|
342
|
-
finally {
|
|
343
|
-
conn.close();
|
|
344
|
-
}
|
|
345
401
|
}
|
|
346
402
|
/** Classic 2025-11-25 contact: initialize → notifications/initialized → tools/list. */
|
|
347
403
|
async function tryClassic(target, opts) {
|
|
@@ -349,6 +405,7 @@ async function tryClassic(target, opts) {
|
|
|
349
405
|
try {
|
|
350
406
|
const init = await conn.request('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: clientInfo() }, opts.timeoutMs);
|
|
351
407
|
if (init.error) {
|
|
408
|
+
conn.close();
|
|
352
409
|
return {
|
|
353
410
|
ok: false,
|
|
354
411
|
evidence: `initialize was rejected: ${init.error.code ?? '?'} ${init.error.message ?? ''}`.trim(),
|
|
@@ -357,6 +414,7 @@ async function tryClassic(target, opts) {
|
|
|
357
414
|
await conn.notify('notifications/initialized', {});
|
|
358
415
|
const lst = await conn.request('tools/list', {}, opts.timeoutMs);
|
|
359
416
|
if (lst.error) {
|
|
417
|
+
conn.close();
|
|
360
418
|
return {
|
|
361
419
|
ok: false,
|
|
362
420
|
evidence: `tools/list after handshake was rejected: ${lst.error.code ?? '?'} ${lst.error.message ?? ''}`.trim(),
|
|
@@ -364,26 +422,106 @@ async function tryClassic(target, opts) {
|
|
|
364
422
|
}
|
|
365
423
|
const tools = lst.result?.tools;
|
|
366
424
|
if (!Array.isArray(tools)) {
|
|
425
|
+
conn.close();
|
|
367
426
|
return { ok: false, evidence: 'tools/list after handshake answered without a tools array' };
|
|
368
427
|
}
|
|
369
|
-
return { ok: true, tools, evidence: 'initialize handshake + tools/list succeeded' };
|
|
428
|
+
return { ok: true, tools, conn, evidence: 'initialize handshake + tools/list succeeded' };
|
|
370
429
|
}
|
|
371
430
|
catch (err) {
|
|
431
|
+
conn.close();
|
|
372
432
|
return { ok: false, evidence: err.message };
|
|
373
433
|
}
|
|
374
|
-
|
|
375
|
-
|
|
434
|
+
}
|
|
435
|
+
/** server/discover is REQUIRED on 2026-07-28 (SEP-2575) and must advertise capabilities. */
|
|
436
|
+
async function checkServerDiscover(conn, label, opts) {
|
|
437
|
+
try {
|
|
438
|
+
const resp = await conn.request('server/discover', { _meta: statelessMeta() }, opts.timeoutMs);
|
|
439
|
+
if (resp.error) {
|
|
440
|
+
const evidence = `server/discover was rejected: ${resp.error.code ?? '?'} ${resp.error.message ?? ''}`.trim();
|
|
441
|
+
return {
|
|
442
|
+
ok: false,
|
|
443
|
+
finding: runtimeFinding('missing-server-discover', label, evidence, 'high'),
|
|
444
|
+
note: `server/discover: rejected (${resp.error.code ?? '?'})`,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const caps = resp.result?.capabilities;
|
|
448
|
+
if (!caps || typeof caps !== 'object') {
|
|
449
|
+
const evidence = 'server/discover answered, but its result has no capabilities key';
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
finding: runtimeFinding('missing-server-discover', label, evidence, 'high'),
|
|
453
|
+
note: 'server/discover: result missing the required capabilities key',
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const versions = Array.isArray(resp.result?.supportedVersions)
|
|
457
|
+
? ` · supportedVersions: ${resp.result.supportedVersions.join(', ')}`
|
|
458
|
+
: '';
|
|
459
|
+
return {
|
|
460
|
+
ok: true,
|
|
461
|
+
note: `server/discover: capabilities advertised (${Object.keys(caps).join(', ') || 'empty object'})${versions}`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
if (err instanceof TimeoutError) {
|
|
466
|
+
// No reply at all — a compliant server (or a legacy one) would at least
|
|
467
|
+
// answer -32601. A hang is a failure, but not a deterministic one.
|
|
468
|
+
return {
|
|
469
|
+
ok: false,
|
|
470
|
+
finding: runtimeFinding('missing-server-discover', label, `server/discover hung (${err.message})`, 'medium'),
|
|
471
|
+
note: 'server/discover: no response (hung)',
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return { ok: null, note: `server/discover: check inconclusive — ${err.message}` };
|
|
376
475
|
}
|
|
377
476
|
}
|
|
378
|
-
|
|
379
|
-
|
|
477
|
+
/** A URI no real server should resolve — used to elicit the not-found error code. */
|
|
478
|
+
const NONEXISTENT_URI = 'mcp-vet://probe/nonexistent-resource';
|
|
479
|
+
/** 2026-07-28 changes resource-not-found from -32002 to -32602 (Invalid Params). */
|
|
480
|
+
async function checkResourceErrorCode(conn, label, opts) {
|
|
481
|
+
try {
|
|
482
|
+
const resp = await conn.request('resources/read', { uri: NONEXISTENT_URI, _meta: statelessMeta() }, opts.timeoutMs);
|
|
483
|
+
if (!resp.error) {
|
|
484
|
+
return {
|
|
485
|
+
ok: null,
|
|
486
|
+
note: `resource error-code check inconclusive — resources/read of ${NONEXISTENT_URI} unexpectedly succeeded`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
const code = resp.error.code;
|
|
490
|
+
if (code === -32002) {
|
|
491
|
+
const evidence = `resources/read of nonexistent ${NONEXISTENT_URI} returned the removed code -32002 (${resp.error.message ?? ''})`.trim();
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
finding: runtimeFinding('legacy-resource-error-code', label, evidence, 'high'),
|
|
495
|
+
note: 'resource error code: -32002 (legacy — must be -32602)',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (code === -32602) {
|
|
499
|
+
return { ok: true, note: 'resource error code: -32602 (Invalid Params — correct for 2026-07-28)' };
|
|
500
|
+
}
|
|
501
|
+
if (code === -32601) {
|
|
502
|
+
return {
|
|
503
|
+
ok: null,
|
|
504
|
+
note: 'resource error-code check skipped — server does not implement resources/read (-32601)',
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
ok: null,
|
|
509
|
+
note: `resource error-code check inconclusive — nonexistent resource returned ${code ?? 'no code'} (neither -32002 nor -32602)`,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
return { ok: null, note: `resource error-code check inconclusive — ${err.message}` };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function runtimeFinding(ruleId, targetLabel, evidence, confidence = 'high') {
|
|
517
|
+
const rule = rules_1.RUNTIME_RULES[ruleId];
|
|
380
518
|
return {
|
|
381
519
|
file: targetLabel,
|
|
382
520
|
line: 1,
|
|
383
521
|
patternId: rule.id,
|
|
384
522
|
patternLabel: rule.label,
|
|
385
523
|
severity: rule.severity,
|
|
386
|
-
confidence
|
|
524
|
+
confidence,
|
|
387
525
|
explanation: rule.explanation,
|
|
388
526
|
docUrl: rule.docUrl,
|
|
389
527
|
before: evidence,
|
|
@@ -412,6 +550,161 @@ function dialectFinding(targetLabel, toolName, field, issue) {
|
|
|
412
550
|
function targetLabel(target) {
|
|
413
551
|
return target.kind === 'http' ? target.url : [target.command, ...target.args].join(' ');
|
|
414
552
|
}
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// `--spec 2026-07-28` compliance suite — opt-in, run IN ADDITION to the checks
|
|
555
|
+
// above. Each check uses its own fresh connection so the existing probe path is
|
|
556
|
+
// left exactly as it was.
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
const SESSION_ERR_RE = /session/i;
|
|
559
|
+
const UNINIT_ERR_RE = /initial/i; // matches initialize / initialized / uninitialized
|
|
560
|
+
function delay(ms) {
|
|
561
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
562
|
+
}
|
|
563
|
+
/** Resolve once `pred()` is true or `windowMs` elapses (cheap polling). */
|
|
564
|
+
async function waitUntil(pred, windowMs) {
|
|
565
|
+
const deadline = Date.now() + windowMs;
|
|
566
|
+
while (!pred() && Date.now() < deadline)
|
|
567
|
+
await delay(25);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Runs the six `--spec 2026-07-28` compliance checks and returns the findings
|
|
571
|
+
* plus human-readable notes to fold into the ProbeResult:
|
|
572
|
+
* 1. stateless-no-session ERROR — a no-session request must not be refused
|
|
573
|
+
* 2. stateless-no-init ERROR — a no-handshake request must be answered
|
|
574
|
+
* 3. required-headers ERROR — Mcp-Method/Mcp-Name must be accepted (HTTP)
|
|
575
|
+
* 4. deprecated-sampling WARN — server issued sampling/createMessage
|
|
576
|
+
* 5. deprecated-roots WARN — roots/list returned a result
|
|
577
|
+
* 6. deprecated-logging WARN — server emitted notifications/message
|
|
578
|
+
*/
|
|
579
|
+
async function runSpecChecks(target, opts) {
|
|
580
|
+
const label = targetLabel(target);
|
|
581
|
+
const findings = [];
|
|
582
|
+
const notes = [];
|
|
583
|
+
const isHttp = target.kind === 'http';
|
|
584
|
+
const conn = openConnection(target, '2026-07-28');
|
|
585
|
+
// Passively watch for deprecated server→client traffic while we drive the
|
|
586
|
+
// connection (checks 4 & 6). Register before the first request so the window
|
|
587
|
+
// covers everything the server sends.
|
|
588
|
+
let sawSampling = false;
|
|
589
|
+
let sawLogging = false;
|
|
590
|
+
const unsub = conn.onServerMessage?.((m) => {
|
|
591
|
+
if (m && m.method === 'sampling/createMessage')
|
|
592
|
+
sawSampling = true;
|
|
593
|
+
if (m && m.method === 'notifications/message')
|
|
594
|
+
sawLogging = true;
|
|
595
|
+
});
|
|
596
|
+
try {
|
|
597
|
+
// Checks 1 & 2 — a single stateless (no session), no-initialize tools/list.
|
|
598
|
+
let resp;
|
|
599
|
+
try {
|
|
600
|
+
resp = await conn.request('tools/list', { _meta: statelessMeta() }, opts.timeoutMs);
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
resp = { error: { message: err.message } };
|
|
604
|
+
}
|
|
605
|
+
if (resp.error) {
|
|
606
|
+
const code = resp.error.code;
|
|
607
|
+
const message = resp.error.message ?? '';
|
|
608
|
+
const evidence = `stateless tools/list (no session, no initialize) was rejected: ${code ?? '?'} ${message}`.trim();
|
|
609
|
+
const isSession = SESSION_ERR_RE.test(message);
|
|
610
|
+
const isUninit = UNINIT_ERR_RE.test(message);
|
|
611
|
+
if (isSession) {
|
|
612
|
+
findings.push(runtimeFinding('stateless-no-session', label, evidence));
|
|
613
|
+
notes.push(`stateless-no-session: FAIL — server requires a session (${code ?? '?'})`);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
notes.push('stateless-no-session: passed — no session was required');
|
|
617
|
+
}
|
|
618
|
+
// Per the brief, an "uninitialized" OR a "session" rejection fails no-init.
|
|
619
|
+
if (isUninit || isSession) {
|
|
620
|
+
findings.push(runtimeFinding('stateless-no-init', label, evidence));
|
|
621
|
+
notes.push(`stateless-no-init: FAIL — request rejected without an initialize handshake (${code ?? '?'})`);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
notes.push(`stateless-no-init: inconclusive — rejected for an unrelated reason (${code ?? '?'})`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
else if (Array.isArray(resp.result?.tools)) {
|
|
628
|
+
notes.push('stateless-no-session: passed — no session was required');
|
|
629
|
+
notes.push('stateless-no-init: passed — answered the first request with no handshake');
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
notes.push('stateless-no-session / stateless-no-init: inconclusive — no tools array in the answer');
|
|
633
|
+
}
|
|
634
|
+
// Check 3 — the required Mcp-Method / Mcp-Name routing headers (HTTP only;
|
|
635
|
+
// stdio has no request headers).
|
|
636
|
+
if (!isHttp) {
|
|
637
|
+
notes.push('required-headers: skipped — Mcp-Method/Mcp-Name are a Streamable HTTP concern; target is stdio');
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
let hdrResp;
|
|
641
|
+
try {
|
|
642
|
+
hdrResp = await conn.request('tools/list', { _meta: statelessMeta() }, opts.timeoutMs, {
|
|
643
|
+
'mcp-method': 'tools/list',
|
|
644
|
+
'mcp-name': 'tools/list',
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
hdrResp = { error: { message: err.message } };
|
|
649
|
+
}
|
|
650
|
+
if (!hdrResp.error) {
|
|
651
|
+
notes.push('required-headers: passed — server accepted a request carrying Mcp-Method and Mcp-Name');
|
|
652
|
+
}
|
|
653
|
+
else if (/header|mcp-method|mcp-name|routing/i.test(hdrResp.error.message ?? '')) {
|
|
654
|
+
const evidence = `tools/list carrying the Mcp-Method/Mcp-Name headers was rejected: ${hdrResp.error.code ?? '?'} ${hdrResp.error.message ?? ''}`.trim();
|
|
655
|
+
findings.push(runtimeFinding('required-headers', label, evidence));
|
|
656
|
+
notes.push('required-headers: FAIL — server errored on the required routing headers');
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
notes.push(`required-headers: inconclusive — request errored for an unrelated reason (${hdrResp.error.code ?? '?'})`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// Check 5 — a roots/list that returns a result means the deprecated roots
|
|
663
|
+
// capability is in use.
|
|
664
|
+
try {
|
|
665
|
+
const rootsResp = await conn.request('roots/list', { _meta: statelessMeta() }, opts.timeoutMs);
|
|
666
|
+
if (!rootsResp.error && rootsResp.result && typeof rootsResp.result === 'object') {
|
|
667
|
+
const roots = rootsResp.result.roots;
|
|
668
|
+
const count = Array.isArray(roots) ? ` (${roots.length} root(s))` : '';
|
|
669
|
+
findings.push(runtimeFinding('deprecated-roots', label, `roots/list returned a result${count} — the deprecated roots capability is in use`));
|
|
670
|
+
notes.push('deprecated-roots: WARN — server answered roots/list with a result');
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
notes.push(`deprecated-roots: clean — roots/list not served (${rootsResp.error?.code ?? 'no result'})`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
notes.push(`deprecated-roots: inconclusive — ${err.message}`);
|
|
678
|
+
}
|
|
679
|
+
// Checks 4 & 6 — give the server the spec's window (up to 5 s) to emit
|
|
680
|
+
// deprecated server→client traffic, resolving early once both are seen.
|
|
681
|
+
if (conn.onServerMessage) {
|
|
682
|
+
await waitUntil(() => sawSampling && sawLogging, Math.min(5000, opts.timeoutMs));
|
|
683
|
+
if (sawSampling) {
|
|
684
|
+
findings.push(runtimeFinding('deprecated-sampling', label, 'server issued a sampling/createMessage request'));
|
|
685
|
+
notes.push('deprecated-sampling: WARN — server issued sampling/createMessage');
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
notes.push('deprecated-sampling: clean — no sampling/createMessage observed');
|
|
689
|
+
}
|
|
690
|
+
if (sawLogging) {
|
|
691
|
+
findings.push(runtimeFinding('deprecated-logging', label, 'server emitted a notifications/message log notification'));
|
|
692
|
+
notes.push('deprecated-logging: WARN — server emitted notifications/message');
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
notes.push('deprecated-logging: clean — no notifications/message observed');
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
notes.push('deprecated-sampling / deprecated-logging: skipped — this transport cannot observe server-initiated traffic');
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
unsub?.();
|
|
704
|
+
conn.close();
|
|
705
|
+
}
|
|
706
|
+
return { findings, notes };
|
|
707
|
+
}
|
|
415
708
|
async function probeServer(target, opts) {
|
|
416
709
|
const label = targetLabel(target);
|
|
417
710
|
const findings = [];
|
|
@@ -419,35 +712,58 @@ async function probeServer(target, opts) {
|
|
|
419
712
|
let tools = null;
|
|
420
713
|
let statelessOk = null;
|
|
421
714
|
let handshakeOk = null;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (
|
|
427
|
-
|
|
715
|
+
let discoverOk = null;
|
|
716
|
+
let errorCodeOk = null;
|
|
717
|
+
let conn = null;
|
|
718
|
+
try {
|
|
719
|
+
if (opts.specVersion === '2026-07-28') {
|
|
720
|
+
const st = await tryStateless(target, opts);
|
|
721
|
+
statelessOk = st.ok;
|
|
722
|
+
notes.push(`stateless probe: ${st.evidence}`);
|
|
723
|
+
if (st.ok) {
|
|
724
|
+
tools = st.tools;
|
|
725
|
+
conn = st.conn;
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
const cl = await tryClassic(target, opts);
|
|
729
|
+
handshakeOk = cl.ok;
|
|
730
|
+
if (cl.ok) {
|
|
731
|
+
// Confirmed: the server works — but only through the removed handshake.
|
|
732
|
+
tools = cl.tools;
|
|
733
|
+
conn = cl.conn;
|
|
734
|
+
notes.push(`fallback probe: ${cl.evidence}`);
|
|
735
|
+
findings.push(runtimeFinding('requires-initialize-handshake', label, st.evidence));
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
throw new ProbeError(`could not reach the server statelessly (${st.evidence}) nor via the 2025-11-25 initialize handshake (${cl.evidence}) — is it running and speaking MCP?`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// The remaining 2026-07-28 checks run on whichever contact path worked,
|
|
742
|
+
// so even a handshake-only server gets a complete migration report.
|
|
743
|
+
const disc = await checkServerDiscover(conn, label, opts);
|
|
744
|
+
discoverOk = disc.ok;
|
|
745
|
+
notes.push(disc.note);
|
|
746
|
+
if (disc.finding)
|
|
747
|
+
findings.push(disc.finding);
|
|
748
|
+
const ec = await checkResourceErrorCode(conn, label, opts);
|
|
749
|
+
errorCodeOk = ec.ok;
|
|
750
|
+
notes.push(ec.note);
|
|
751
|
+
if (ec.finding)
|
|
752
|
+
findings.push(ec.finding);
|
|
428
753
|
}
|
|
429
754
|
else {
|
|
430
755
|
const cl = await tryClassic(target, opts);
|
|
431
756
|
handshakeOk = cl.ok;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
notes.push(`fallback probe: ${cl.evidence}`);
|
|
436
|
-
findings.push(handshakeFinding(label, st.evidence));
|
|
437
|
-
}
|
|
438
|
-
else {
|
|
439
|
-
throw new ProbeError(`could not reach the server statelessly (${st.evidence}) nor via the 2025-11-25 initialize handshake (${cl.evidence}) — is it running and speaking MCP?`);
|
|
757
|
+
notes.push(`handshake probe: ${cl.evidence}`);
|
|
758
|
+
if (!cl.ok) {
|
|
759
|
+
throw new ProbeError(`could not connect: ${cl.evidence}`);
|
|
440
760
|
}
|
|
761
|
+
tools = cl.tools;
|
|
762
|
+
conn = cl.conn;
|
|
441
763
|
}
|
|
442
764
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
handshakeOk = cl.ok;
|
|
446
|
-
notes.push(`handshake probe: ${cl.evidence}`);
|
|
447
|
-
if (!cl.ok) {
|
|
448
|
-
throw new ProbeError(`could not connect: ${cl.evidence}`);
|
|
449
|
-
}
|
|
450
|
-
tools = cl.tools;
|
|
765
|
+
finally {
|
|
766
|
+
conn?.close();
|
|
451
767
|
}
|
|
452
768
|
for (const tool of tools) {
|
|
453
769
|
if (!tool || typeof tool !== 'object')
|
|
@@ -459,6 +775,13 @@ async function probeServer(target, opts) {
|
|
|
459
775
|
findings.push(dialectFinding(label, name, field, issue));
|
|
460
776
|
}
|
|
461
777
|
}
|
|
778
|
+
// The opt-in `--spec 2026-07-28` compliance suite runs on its own fresh
|
|
779
|
+
// connection(s), in addition to everything above.
|
|
780
|
+
if (opts.specChecks && opts.specVersion === '2026-07-28') {
|
|
781
|
+
const extra = await runSpecChecks(target, opts);
|
|
782
|
+
findings.push(...extra.findings);
|
|
783
|
+
notes.push(...extra.notes);
|
|
784
|
+
}
|
|
462
785
|
return {
|
|
463
786
|
target: label,
|
|
464
787
|
transport: target.kind,
|
|
@@ -467,6 +790,8 @@ async function probeServer(target, opts) {
|
|
|
467
790
|
toolCount: tools.length,
|
|
468
791
|
statelessOk,
|
|
469
792
|
handshakeOk,
|
|
793
|
+
discoverOk,
|
|
794
|
+
errorCodeOk,
|
|
470
795
|
notes,
|
|
471
796
|
};
|
|
472
797
|
}
|
package/dist/reporters.js
CHANGED
|
@@ -286,7 +286,7 @@ function reportProbeTerminal(result, opts = {}) {
|
|
|
286
286
|
if (findings.length === 0) {
|
|
287
287
|
console.log(c.green(`✔ no runtime violations — ` +
|
|
288
288
|
(result.specVersion === '2026-07-28'
|
|
289
|
-
? 'the server answers stateless 2026-07-28 requests and all tool schemas are JSON Schema 2020-12 compatible'
|
|
289
|
+
? 'the server answers stateless 2026-07-28 requests, implements server/discover, uses the new resource error code, and all tool schemas are JSON Schema 2020-12 compatible'
|
|
290
290
|
: 'all tool schemas are JSON Schema 2020-12 compatible')));
|
|
291
291
|
return;
|
|
292
292
|
}
|
package/dist/rules.js
CHANGED
|
@@ -113,6 +113,71 @@ exports.RUNTIME_RULES = {
|
|
|
113
113
|
after: 'Update your SDK to @modelcontextprotocol/server (the new 2026-07-28 package) and remove any initialize handler assumptions',
|
|
114
114
|
docUrl: constants_1.SPEC_URL,
|
|
115
115
|
},
|
|
116
|
+
'missing-server-discover': {
|
|
117
|
+
id: 'missing-server-discover',
|
|
118
|
+
label: 'server/discover not implemented',
|
|
119
|
+
severity: 'ERROR',
|
|
120
|
+
explanation: 'The 2026-07-28 spec requires every server to implement the server/discover RPC (SEP-2575) — it replaces the removed initialize handshake as the way clients fetch supported protocol versions, capabilities, and identity. This server did not answer it with a result containing a capabilities key.',
|
|
121
|
+
after: 'Implement server/discover returning { capabilities, supportedVersions, ... } — @modelcontextprotocol/server (the 2026-07-28 SDK) answers it for you automatically.',
|
|
122
|
+
docUrl: constants_1.SPEC_URL,
|
|
123
|
+
},
|
|
124
|
+
'legacy-resource-error-code': {
|
|
125
|
+
id: 'legacy-resource-error-code',
|
|
126
|
+
label: 'legacy -32002 resource error code',
|
|
127
|
+
severity: 'ERROR',
|
|
128
|
+
explanation: 'Reading a nonexistent resource returned the MCP-custom error code -32002; the 2026-07-28 spec changes it to the JSON-RPC standard -32602 (Invalid Params). Clients matching on the new code will misclassify this error.',
|
|
129
|
+
after: "return { error: { code: -32602, message: 'Invalid params' } }; // was -32002 — the static scan's --fix rewrites source occurrences",
|
|
130
|
+
docUrl: constants_1.SPEC_URL,
|
|
131
|
+
},
|
|
132
|
+
// --- `--spec 2026-07-28` compliance suite (added on top of the four above) ---
|
|
133
|
+
'stateless-no-session': {
|
|
134
|
+
id: 'stateless-no-session',
|
|
135
|
+
label: 'requires a protocol-level session',
|
|
136
|
+
severity: 'ERROR',
|
|
137
|
+
explanation: 'A stateless tools/list sent with no Mcp-Session-Id was rejected with a session error. The 2026-07-28 spec removes the Mcp-Session-Id header and the protocol-level session (SEP-2567); the server must serve requests without one.',
|
|
138
|
+
after: 'Stop requiring a session: remove sessionIdGenerator / the Mcp-Session-Id gate and serve each request statelessly (client identity & capabilities arrive in per-request _meta).',
|
|
139
|
+
docUrl: constants_1.SPEC_URL,
|
|
140
|
+
},
|
|
141
|
+
'stateless-no-init': {
|
|
142
|
+
id: 'stateless-no-init',
|
|
143
|
+
label: 'requires initialization before requests',
|
|
144
|
+
severity: 'ERROR',
|
|
145
|
+
explanation: 'A tools/list sent without a prior initialize/initialized handshake was rejected as uninitialized. The 2026-07-28 spec removes the handshake (SEP-2575); a compliant server answers the first request directly.',
|
|
146
|
+
after: 'Remove the initialize/initialized gate and answer requests immediately; read protocolVersion/clientInfo/capabilities from params._meta on every request.',
|
|
147
|
+
docUrl: constants_1.SPEC_URL,
|
|
148
|
+
},
|
|
149
|
+
'required-headers': {
|
|
150
|
+
id: 'required-headers',
|
|
151
|
+
label: 'rejects the required Mcp-Method / Mcp-Name headers',
|
|
152
|
+
severity: 'ERROR',
|
|
153
|
+
explanation: 'The 2026-07-28 Streamable HTTP transport requires each request to carry Mcp-Method and Mcp-Name routing headers that mirror the JSON-RPC body. This server errored on a request that set them, so it will reject conforming 2026-07-28 clients.',
|
|
154
|
+
after: 'Accept (and, per spec, validate against the body) the Mcp-Method and Mcp-Name request headers rather than rejecting them.',
|
|
155
|
+
docUrl: constants_1.SPEC_URL,
|
|
156
|
+
},
|
|
157
|
+
'deprecated-sampling': {
|
|
158
|
+
id: 'deprecated-sampling',
|
|
159
|
+
label: 'uses deprecated sampling (sampling/createMessage)',
|
|
160
|
+
severity: 'WARN',
|
|
161
|
+
explanation: 'The server issued a sampling/createMessage request. Sampling is deprecated in 2026-07-28 and eligible for removal in July 2027; the server-driven LLM call is being phased out.',
|
|
162
|
+
after: 'Migrate off sampling: call your LLM provider’s API directly from the server instead of asking the client to sample. Eligible for removal July 2027.',
|
|
163
|
+
docUrl: constants_1.SPEC_URL,
|
|
164
|
+
},
|
|
165
|
+
'deprecated-roots': {
|
|
166
|
+
id: 'deprecated-roots',
|
|
167
|
+
label: 'uses deprecated roots',
|
|
168
|
+
severity: 'WARN',
|
|
169
|
+
explanation: 'The server answered roots/list with a result, indicating it relies on the roots capability, which is deprecated in 2026-07-28.',
|
|
170
|
+
after: 'Migrate off roots: pass the paths/URIs the server needs as explicit tool parameters instead of discovering them via the roots capability.',
|
|
171
|
+
docUrl: constants_1.SPEC_URL,
|
|
172
|
+
},
|
|
173
|
+
'deprecated-logging': {
|
|
174
|
+
id: 'deprecated-logging',
|
|
175
|
+
label: 'uses deprecated MCP logging (notifications/message)',
|
|
176
|
+
severity: 'WARN',
|
|
177
|
+
explanation: 'The server emitted a notifications/message log notification. The MCP logging protocol is deprecated in 2026-07-28.',
|
|
178
|
+
after: 'Migrate off MCP logging: write logs to stderr (stdio transport) or emit OpenTelemetry instead of notifications/message.',
|
|
179
|
+
docUrl: constants_1.SPEC_URL,
|
|
180
|
+
},
|
|
116
181
|
};
|
|
117
182
|
const CAP_RE = /capabilities/i;
|
|
118
183
|
const CAP_NAMES = {
|
package/dist/types.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export declare const ALL_PATTERN_IDS: PatternId[];
|
|
|
15
15
|
* not by static source analysis. Kebab-case ids are deliberate — they are the
|
|
16
16
|
* wire-level category names, distinct from the static PatternId rule ids.
|
|
17
17
|
*/
|
|
18
|
-
export type RuntimeRuleId = 'json-schema-dialect' | 'requires-initialize-handshake';
|
|
18
|
+
export type RuntimeRuleId = 'json-schema-dialect' | 'requires-initialize-handshake' | 'missing-server-discover' | 'legacy-resource-error-code' | 'stateless-no-session' | 'stateless-no-init' | 'required-headers' | 'deprecated-sampling' | 'deprecated-roots' | 'deprecated-logging';
|
|
19
19
|
export declare const ALL_RUNTIME_RULE_IDS: RuntimeRuleId[];
|
|
20
20
|
/** Any violation id — static pattern or runtime probe category. */
|
|
21
21
|
export type ViolationId = PatternId | RuntimeRuleId;
|
package/dist/types.js
CHANGED
|
@@ -16,4 +16,12 @@ exports.ALL_PATTERN_IDS = [
|
|
|
16
16
|
exports.ALL_RUNTIME_RULE_IDS = [
|
|
17
17
|
'json-schema-dialect',
|
|
18
18
|
'requires-initialize-handshake',
|
|
19
|
+
'missing-server-discover',
|
|
20
|
+
'legacy-resource-error-code',
|
|
21
|
+
'stateless-no-session',
|
|
22
|
+
'stateless-no-init',
|
|
23
|
+
'required-headers',
|
|
24
|
+
'deprecated-sampling',
|
|
25
|
+
'deprecated-roots',
|
|
26
|
+
'deprecated-logging',
|
|
19
27
|
];
|
package/package.json
CHANGED