@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 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 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,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, and `probe` catches all of them:
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 mcp-server-everything stdio
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 (55 tests)
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, and one fully stateless 2026-07-28-native.
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 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;',
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
- if (!types_1.SPEC_VERSIONS.includes(opts.specVersion)) {
84
- fail(`invalid --spec-version "${opts.specVersion}". Valid: ${types_1.SPEC_VERSIONS.join(', ')}`);
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 = opts.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
- request(method, params, timeoutMs) {
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 post = async (payload, method, timeoutMs) => {
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
- msg = Array.isArray(parsed) ? parsed.find((m) => m && m.id === id) ?? null : parsed;
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 first request (per the RC's namespaced keys). */
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/capabilities': {},
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
- finally {
375
- conn.close();
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
- function handshakeFinding(targetLabel, evidence) {
379
- const rule = rules_1.RUNTIME_RULES['requires-initialize-handshake'];
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: 'high',
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
- 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;
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
- 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?`);
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
- 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;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@booyaka/mcp-vet",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",