@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 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 two 2026-07-28 violations that only exist at runtime:
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`, capabilities/clientInfo/protocolVersion in `_meta` per the RC — and flags a server that rejects it or hangs waiting for the removed handshake |
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 + schema dialects
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, and `probe` catches all of them:
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 mcp-server-everything stdio
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 (55 tests)
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, and one fully stateless 2026-07-28-native.
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 readiness)',
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 first request (per the RC's namespaced keys). */
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/capabilities': {},
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
- finally {
375
- conn.close();
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
- function handshakeFinding(targetLabel, evidence) {
379
- const rule = rules_1.RUNTIME_RULES['requires-initialize-handshake'];
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: 'high',
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
- if (opts.specVersion === '2026-07-28') {
423
- const st = await tryStateless(target, opts);
424
- statelessOk = st.ok;
425
- notes.push(`stateless probe: ${st.evidence}`);
426
- if (st.ok) {
427
- tools = st.tools;
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
- if (cl.ok) {
433
- // Confirmed: the server works — but only through the removed handshake.
434
- tools = cl.tools;
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
- else {
444
- const cl = await tryClassic(target, opts);
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
@@ -16,4 +16,6 @@ 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',
19
21
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@booyaka/mcp-vet",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Scan MCP server source code for patterns that break under the 2026-07-28 Model Context Protocol spec release candidate.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",