@booyaka/mcp-vet 0.5.0 → 0.6.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 +38 -0
- package/README.md +30 -9
- package/dist/cli.js +6 -4
- package/dist/probe-cli.js +1 -1
- package/dist/probe.d.ts +4 -0
- package/dist/probe.js +151 -35
- package/dist/reporters.js +1 -1
- package/dist/rules.js +16 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ 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.6.0]
|
|
8
|
+
|
|
9
|
+
The full 2026-07-28 compliance suite — `mcp-vet probe --spec-version 2026-07-28`
|
|
10
|
+
now covers all three breaking changes the release candidate makes to server
|
|
11
|
+
behavior, not just the removed handshake.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`missing-server-discover` (ERROR, `--spec-version 2026-07-28`)** — calls
|
|
16
|
+
the `server/discover` RPC that every 2026-07-28 server MUST implement
|
|
17
|
+
(SEP-2575; it replaces the removed initialize handshake for up-front
|
|
18
|
+
capability discovery) and flags a server whose answer is an error, a hang, or
|
|
19
|
+
a result missing the required `capabilities` key. The spec defines
|
|
20
|
+
`server/discover` as JSON-RPC only — 2026-07-28 removes the HTTP GET
|
|
21
|
+
endpoint, so no `GET /mcp/discover` variant is probed.
|
|
22
|
+
- **`legacy-resource-error-code` (ERROR, `--spec-version 2026-07-28`)** — reads
|
|
23
|
+
a deliberately nonexistent resource URI (`mcp-vet://probe/...`) and flags a
|
|
24
|
+
server that still answers `-32002` instead of the JSON-RPC standard `-32602`
|
|
25
|
+
(Invalid Params). Servers without `resources/read` (`-32601`) are skipped,
|
|
26
|
+
not flagged; unexpected codes are reported as inconclusive notes.
|
|
27
|
+
- **`mcp-vet run`** — an alias for `mcp-vet probe`.
|
|
28
|
+
- **`test/probe-fixtures/server-partial.mjs`** — a hybrid (handshake +
|
|
29
|
+
stateless) fixture with exactly one migration defect per mode
|
|
30
|
+
(`legacy-error-code` / `no-discover` / `bad-discover`), isolating each new
|
|
31
|
+
rule; 7 new tests (62 total), including proof that the new checks are gated
|
|
32
|
+
behind `--spec-version 2026-07-28` and the default probe is unchanged.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- The stateless first request now sends the RC's exact namespaced `_meta` key
|
|
37
|
+
`io.modelcontextprotocol/clientCapabilities` (was the incorrect
|
|
38
|
+
`io.modelcontextprotocol/capabilities`); the stateless fixtures now *require*
|
|
39
|
+
the namespaced keys, locking the wire format into the tests.
|
|
40
|
+
- The new checks run on whichever contact path succeeded (stateless or classic
|
|
41
|
+
fallback), so a handshake-only legacy server gets its complete 2026-07-28
|
|
42
|
+
migration report — handshake + discover findings — in a single probe.
|
|
43
|
+
- `ProbeResult` gains `discoverOk` and `errorCodeOk` verdict fields.
|
|
44
|
+
|
|
7
45
|
## [0.5.0]
|
|
8
46
|
|
|
9
47
|
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,39 @@ 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)).
|
|
245
266
|
|
|
246
267
|
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
268
|
|
|
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
|
|
269
|
+
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
270
|
|
|
250
271
|
```bash
|
|
251
|
-
npx @booyaka/mcp-vet probe --spec-version 2026-07-28 npx -y
|
|
272
|
+
npx @booyaka/mcp-vet probe --spec-version 2026-07-28 npx -y @modelcontextprotocol/server-everything stdio
|
|
252
273
|
```
|
|
253
274
|
|
|
254
275
|
## Usage
|
|
@@ -259,7 +280,7 @@ npx @booyaka/mcp-vet . --fix # scan, and auto-apply the mechanical -32
|
|
|
259
280
|
npx @booyaka/mcp-vet ./src ./packages # multiple roots
|
|
260
281
|
npx @booyaka/mcp-vet server.py # a single file
|
|
261
282
|
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)
|
|
283
|
+
npx @booyaka/mcp-vet probe <url|cmd> # vet a RUNNING server's wire behavior (see section above; alias: run)
|
|
263
284
|
```
|
|
264
285
|
|
|
265
286
|
Globs `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and `**/*.py`, skipping `node_modules`, `.git`, `__pycache__`, `dist`, and `build`.
|
|
@@ -465,10 +486,10 @@ Also exported: `renderJson` / `renderMarkdown` / `renderSarif`, `RULES`, and the
|
|
|
465
486
|
```bash
|
|
466
487
|
npm install # installs deps and builds (via prepare)
|
|
467
488
|
npm run build # tsc -> dist/ + copies the Python script
|
|
468
|
-
npm test # builds, then runs the Node.js built-in test runner (
|
|
489
|
+
npm test # builds, then runs the Node.js built-in test runner (62 tests)
|
|
469
490
|
```
|
|
470
491
|
|
|
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,
|
|
492
|
+
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
493
|
|
|
473
494
|
## License
|
|
474
495
|
|
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,9 @@ 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)',
|
|
144
146
|
].join('\n'))
|
|
145
147
|
.showHelpAfterError();
|
|
146
148
|
program.parse(process.argv);
|
package/dist/probe-cli.js
CHANGED
|
@@ -58,7 +58,7 @@ 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.')
|
|
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
64
|
.option('--timeout <ms>', 'per-request timeout in milliseconds', '8000')
|
package/dist/probe.d.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface ProbeResult {
|
|
|
27
27
|
statelessOk: boolean | null;
|
|
28
28
|
/** classic initialize-handshake verdict; null = not attempted */
|
|
29
29
|
handshakeOk: boolean | null;
|
|
30
|
+
/** server/discover verdict; null = not probed (spec 2025-11-25) */
|
|
31
|
+
discoverOk: boolean | null;
|
|
32
|
+
/** nonexistent-resource error-code verdict; null = not probed or inconclusive */
|
|
33
|
+
errorCodeOk: boolean | null;
|
|
30
34
|
notes: string[];
|
|
31
35
|
}
|
|
32
36
|
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"));
|
|
@@ -305,12 +314,12 @@ function openConnection(target, specVersion) {
|
|
|
305
314
|
function clientInfo() {
|
|
306
315
|
return { name: 'mcp-vet', version: (0, constants_1.getVersion)() };
|
|
307
316
|
}
|
|
308
|
-
/** _meta for a stateless 2026-07-28
|
|
317
|
+
/** _meta for a stateless 2026-07-28 request (per the RC's namespaced keys). */
|
|
309
318
|
function statelessMeta() {
|
|
310
319
|
return {
|
|
311
320
|
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
|
312
321
|
'io.modelcontextprotocol/clientInfo': clientInfo(),
|
|
313
|
-
'io.modelcontextprotocol/
|
|
322
|
+
'io.modelcontextprotocol/clientCapabilities': {},
|
|
314
323
|
};
|
|
315
324
|
}
|
|
316
325
|
/** Stateless 2026-07-28 first contact: tools/list with _meta, NO initialize. */
|
|
@@ -319,6 +328,7 @@ async function tryStateless(target, opts) {
|
|
|
319
328
|
try {
|
|
320
329
|
const resp = await conn.request('tools/list', { _meta: statelessMeta() }, opts.timeoutMs);
|
|
321
330
|
if (resp.error) {
|
|
331
|
+
conn.close();
|
|
322
332
|
return {
|
|
323
333
|
ok: false,
|
|
324
334
|
evidence: `stateless tools/list was rejected: ${resp.error.code ?? '?'} ${resp.error.message ?? ''}`.trim(),
|
|
@@ -326,11 +336,13 @@ async function tryStateless(target, opts) {
|
|
|
326
336
|
}
|
|
327
337
|
const tools = resp.result?.tools;
|
|
328
338
|
if (!Array.isArray(tools)) {
|
|
339
|
+
conn.close();
|
|
329
340
|
return { ok: false, evidence: 'stateless tools/list answered without a tools array' };
|
|
330
341
|
}
|
|
331
|
-
return { ok: true, tools, evidence: 'server answered a stateless tools/list (no initialize)' };
|
|
342
|
+
return { ok: true, tools, conn, evidence: 'server answered a stateless tools/list (no initialize)' };
|
|
332
343
|
}
|
|
333
344
|
catch (err) {
|
|
345
|
+
conn.close();
|
|
334
346
|
if (err instanceof TimeoutError) {
|
|
335
347
|
return {
|
|
336
348
|
ok: false,
|
|
@@ -339,9 +351,6 @@ async function tryStateless(target, opts) {
|
|
|
339
351
|
}
|
|
340
352
|
return { ok: false, evidence: err.message };
|
|
341
353
|
}
|
|
342
|
-
finally {
|
|
343
|
-
conn.close();
|
|
344
|
-
}
|
|
345
354
|
}
|
|
346
355
|
/** Classic 2025-11-25 contact: initialize → notifications/initialized → tools/list. */
|
|
347
356
|
async function tryClassic(target, opts) {
|
|
@@ -349,6 +358,7 @@ async function tryClassic(target, opts) {
|
|
|
349
358
|
try {
|
|
350
359
|
const init = await conn.request('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: clientInfo() }, opts.timeoutMs);
|
|
351
360
|
if (init.error) {
|
|
361
|
+
conn.close();
|
|
352
362
|
return {
|
|
353
363
|
ok: false,
|
|
354
364
|
evidence: `initialize was rejected: ${init.error.code ?? '?'} ${init.error.message ?? ''}`.trim(),
|
|
@@ -357,6 +367,7 @@ async function tryClassic(target, opts) {
|
|
|
357
367
|
await conn.notify('notifications/initialized', {});
|
|
358
368
|
const lst = await conn.request('tools/list', {}, opts.timeoutMs);
|
|
359
369
|
if (lst.error) {
|
|
370
|
+
conn.close();
|
|
360
371
|
return {
|
|
361
372
|
ok: false,
|
|
362
373
|
evidence: `tools/list after handshake was rejected: ${lst.error.code ?? '?'} ${lst.error.message ?? ''}`.trim(),
|
|
@@ -364,26 +375,106 @@ async function tryClassic(target, opts) {
|
|
|
364
375
|
}
|
|
365
376
|
const tools = lst.result?.tools;
|
|
366
377
|
if (!Array.isArray(tools)) {
|
|
378
|
+
conn.close();
|
|
367
379
|
return { ok: false, evidence: 'tools/list after handshake answered without a tools array' };
|
|
368
380
|
}
|
|
369
|
-
return { ok: true, tools, evidence: 'initialize handshake + tools/list succeeded' };
|
|
381
|
+
return { ok: true, tools, conn, evidence: 'initialize handshake + tools/list succeeded' };
|
|
370
382
|
}
|
|
371
383
|
catch (err) {
|
|
384
|
+
conn.close();
|
|
372
385
|
return { ok: false, evidence: err.message };
|
|
373
386
|
}
|
|
374
|
-
|
|
375
|
-
|
|
387
|
+
}
|
|
388
|
+
/** server/discover is REQUIRED on 2026-07-28 (SEP-2575) and must advertise capabilities. */
|
|
389
|
+
async function checkServerDiscover(conn, label, opts) {
|
|
390
|
+
try {
|
|
391
|
+
const resp = await conn.request('server/discover', { _meta: statelessMeta() }, opts.timeoutMs);
|
|
392
|
+
if (resp.error) {
|
|
393
|
+
const evidence = `server/discover was rejected: ${resp.error.code ?? '?'} ${resp.error.message ?? ''}`.trim();
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
finding: runtimeFinding('missing-server-discover', label, evidence, 'high'),
|
|
397
|
+
note: `server/discover: rejected (${resp.error.code ?? '?'})`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const caps = resp.result?.capabilities;
|
|
401
|
+
if (!caps || typeof caps !== 'object') {
|
|
402
|
+
const evidence = 'server/discover answered, but its result has no capabilities key';
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
finding: runtimeFinding('missing-server-discover', label, evidence, 'high'),
|
|
406
|
+
note: 'server/discover: result missing the required capabilities key',
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const versions = Array.isArray(resp.result?.supportedVersions)
|
|
410
|
+
? ` · supportedVersions: ${resp.result.supportedVersions.join(', ')}`
|
|
411
|
+
: '';
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
note: `server/discover: capabilities advertised (${Object.keys(caps).join(', ') || 'empty object'})${versions}`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
if (err instanceof TimeoutError) {
|
|
419
|
+
// No reply at all — a compliant server (or a legacy one) would at least
|
|
420
|
+
// answer -32601. A hang is a failure, but not a deterministic one.
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
finding: runtimeFinding('missing-server-discover', label, `server/discover hung (${err.message})`, 'medium'),
|
|
424
|
+
note: 'server/discover: no response (hung)',
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return { ok: null, note: `server/discover: check inconclusive — ${err.message}` };
|
|
376
428
|
}
|
|
377
429
|
}
|
|
378
|
-
|
|
379
|
-
|
|
430
|
+
/** A URI no real server should resolve — used to elicit the not-found error code. */
|
|
431
|
+
const NONEXISTENT_URI = 'mcp-vet://probe/nonexistent-resource';
|
|
432
|
+
/** 2026-07-28 changes resource-not-found from -32002 to -32602 (Invalid Params). */
|
|
433
|
+
async function checkResourceErrorCode(conn, label, opts) {
|
|
434
|
+
try {
|
|
435
|
+
const resp = await conn.request('resources/read', { uri: NONEXISTENT_URI, _meta: statelessMeta() }, opts.timeoutMs);
|
|
436
|
+
if (!resp.error) {
|
|
437
|
+
return {
|
|
438
|
+
ok: null,
|
|
439
|
+
note: `resource error-code check inconclusive — resources/read of ${NONEXISTENT_URI} unexpectedly succeeded`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const code = resp.error.code;
|
|
443
|
+
if (code === -32002) {
|
|
444
|
+
const evidence = `resources/read of nonexistent ${NONEXISTENT_URI} returned the removed code -32002 (${resp.error.message ?? ''})`.trim();
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
finding: runtimeFinding('legacy-resource-error-code', label, evidence, 'high'),
|
|
448
|
+
note: 'resource error code: -32002 (legacy — must be -32602)',
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
if (code === -32602) {
|
|
452
|
+
return { ok: true, note: 'resource error code: -32602 (Invalid Params — correct for 2026-07-28)' };
|
|
453
|
+
}
|
|
454
|
+
if (code === -32601) {
|
|
455
|
+
return {
|
|
456
|
+
ok: null,
|
|
457
|
+
note: 'resource error-code check skipped — server does not implement resources/read (-32601)',
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
ok: null,
|
|
462
|
+
note: `resource error-code check inconclusive — nonexistent resource returned ${code ?? 'no code'} (neither -32002 nor -32602)`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
return { ok: null, note: `resource error-code check inconclusive — ${err.message}` };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function runtimeFinding(ruleId, targetLabel, evidence, confidence = 'high') {
|
|
470
|
+
const rule = rules_1.RUNTIME_RULES[ruleId];
|
|
380
471
|
return {
|
|
381
472
|
file: targetLabel,
|
|
382
473
|
line: 1,
|
|
383
474
|
patternId: rule.id,
|
|
384
475
|
patternLabel: rule.label,
|
|
385
476
|
severity: rule.severity,
|
|
386
|
-
confidence
|
|
477
|
+
confidence,
|
|
387
478
|
explanation: rule.explanation,
|
|
388
479
|
docUrl: rule.docUrl,
|
|
389
480
|
before: evidence,
|
|
@@ -419,35 +510,58 @@ async function probeServer(target, opts) {
|
|
|
419
510
|
let tools = null;
|
|
420
511
|
let statelessOk = null;
|
|
421
512
|
let handshakeOk = null;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (
|
|
427
|
-
|
|
513
|
+
let discoverOk = null;
|
|
514
|
+
let errorCodeOk = null;
|
|
515
|
+
let conn = null;
|
|
516
|
+
try {
|
|
517
|
+
if (opts.specVersion === '2026-07-28') {
|
|
518
|
+
const st = await tryStateless(target, opts);
|
|
519
|
+
statelessOk = st.ok;
|
|
520
|
+
notes.push(`stateless probe: ${st.evidence}`);
|
|
521
|
+
if (st.ok) {
|
|
522
|
+
tools = st.tools;
|
|
523
|
+
conn = st.conn;
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
const cl = await tryClassic(target, opts);
|
|
527
|
+
handshakeOk = cl.ok;
|
|
528
|
+
if (cl.ok) {
|
|
529
|
+
// Confirmed: the server works — but only through the removed handshake.
|
|
530
|
+
tools = cl.tools;
|
|
531
|
+
conn = cl.conn;
|
|
532
|
+
notes.push(`fallback probe: ${cl.evidence}`);
|
|
533
|
+
findings.push(runtimeFinding('requires-initialize-handshake', label, st.evidence));
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
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?`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// The remaining 2026-07-28 checks run on whichever contact path worked,
|
|
540
|
+
// so even a handshake-only server gets a complete migration report.
|
|
541
|
+
const disc = await checkServerDiscover(conn, label, opts);
|
|
542
|
+
discoverOk = disc.ok;
|
|
543
|
+
notes.push(disc.note);
|
|
544
|
+
if (disc.finding)
|
|
545
|
+
findings.push(disc.finding);
|
|
546
|
+
const ec = await checkResourceErrorCode(conn, label, opts);
|
|
547
|
+
errorCodeOk = ec.ok;
|
|
548
|
+
notes.push(ec.note);
|
|
549
|
+
if (ec.finding)
|
|
550
|
+
findings.push(ec.finding);
|
|
428
551
|
}
|
|
429
552
|
else {
|
|
430
553
|
const cl = await tryClassic(target, opts);
|
|
431
554
|
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?`);
|
|
555
|
+
notes.push(`handshake probe: ${cl.evidence}`);
|
|
556
|
+
if (!cl.ok) {
|
|
557
|
+
throw new ProbeError(`could not connect: ${cl.evidence}`);
|
|
440
558
|
}
|
|
559
|
+
tools = cl.tools;
|
|
560
|
+
conn = cl.conn;
|
|
441
561
|
}
|
|
442
562
|
}
|
|
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;
|
|
563
|
+
finally {
|
|
564
|
+
conn?.close();
|
|
451
565
|
}
|
|
452
566
|
for (const tool of tools) {
|
|
453
567
|
if (!tool || typeof tool !== 'object')
|
|
@@ -467,6 +581,8 @@ async function probeServer(target, opts) {
|
|
|
467
581
|
toolCount: tools.length,
|
|
468
582
|
statelessOk,
|
|
469
583
|
handshakeOk,
|
|
584
|
+
discoverOk,
|
|
585
|
+
errorCodeOk,
|
|
470
586
|
notes,
|
|
471
587
|
};
|
|
472
588
|
}
|
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,22 @@ 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
|
+
},
|
|
116
132
|
};
|
|
117
133
|
const CAP_RE = /capabilities/i;
|
|
118
134
|
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';
|
|
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
package/package.json
CHANGED