@booyaka/mcp-vet 0.6.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,51 @@ 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
+
7
52
  ## [0.6.0]
8
53
 
9
54
  The full 2026-07-28 compliance suite — `mcp-vet probe --spec-version 2026-07-28`
package/README.md CHANGED
@@ -264,6 +264,26 @@ The stateless verdict is **cross-checked** before it becomes a violation: `requi
264
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
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
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.
286
+
267
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`.
268
288
 
269
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):
package/dist/cli.js CHANGED
@@ -142,7 +142,9 @@ function scanMain() {
142
142
  ' fixtures [dir] write protocol-level conformance fixtures + CHECKLIST.md (default: ./mcp-vet-fixtures)',
143
143
  ' probe [options] <url|command> connect to a RUNNING server and vet its wire behavior (alias: run)',
144
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)',
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)',
146
148
  ].join('\n'))
147
149
  .showHelpAfterError();
148
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, the required server/discover RPC (SEP-2575), and the -32002 → -32602 resource error-code change.')
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) */
package/dist/probe.js CHANGED
@@ -115,6 +115,7 @@ function openStdio(rawCommand, args) {
115
115
  let closedReason = '';
116
116
  const stderrTail = [];
117
117
  const pending = new Map();
118
+ const serverListeners = new Set();
118
119
  const failAll = (reason) => {
119
120
  closed = true;
120
121
  closedReason = reason;
@@ -142,6 +143,12 @@ function openStdio(rawCommand, args) {
142
143
  pending.delete(msg.id);
143
144
  p.resolve(msg);
144
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
+ }
145
152
  }
146
153
  });
147
154
  child.stderr.on('data', (d) => {
@@ -156,7 +163,8 @@ function openStdio(rawCommand, args) {
156
163
  (tail ? ` — stderr: ${tail}` : ''));
157
164
  });
158
165
  return {
159
- request(method, params, timeoutMs) {
166
+ // extraHeaders is a Streamable-HTTP concept; stdio has no request headers.
167
+ request(method, params, timeoutMs, _extraHeaders) {
160
168
  return new Promise((resolve, reject) => {
161
169
  if (closed)
162
170
  return reject(new ConnectionClosedError(closedReason));
@@ -195,6 +203,10 @@ function openStdio(rawCommand, args) {
195
203
  /* connection already down — the next request reports it */
196
204
  }
197
205
  },
206
+ onServerMessage(listener) {
207
+ serverListeners.add(listener);
208
+ return () => serverListeners.delete(listener);
209
+ },
198
210
  close() {
199
211
  closed = true;
200
212
  try {
@@ -227,7 +239,16 @@ function parseSse(body, id) {
227
239
  function openHttp(url, specVersion) {
228
240
  let nextId = 1;
229
241
  let sessionId;
230
- 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) => {
231
252
  const headers = {
232
253
  'content-type': 'application/json',
233
254
  accept: 'application/json, text/event-stream',
@@ -238,6 +259,9 @@ function openHttp(url, specVersion) {
238
259
  // mirrors the JSON-RPC body.
239
260
  if (specVersion === '2026-07-28')
240
261
  headers['mcp-method'] = method;
262
+ if (extraHeaders)
263
+ for (const [k, v] of Object.entries(extraHeaders))
264
+ headers[k.toLowerCase()] = v;
241
265
  let res;
242
266
  try {
243
267
  res = await fetch(url, {
@@ -260,19 +284,38 @@ function openHttp(url, specVersion) {
260
284
  return res;
261
285
  };
262
286
  return {
263
- async request(method, params, timeoutMs) {
287
+ async request(method, params, timeoutMs, extraHeaders) {
264
288
  const id = nextId++;
265
- 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);
266
290
  const body = await res.text();
267
291
  const ct = res.headers.get('content-type') ?? '';
268
292
  let msg = null;
269
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
+ }
270
306
  msg = parseSse(body, id);
271
307
  }
272
308
  else {
273
309
  try {
274
310
  const parsed = JSON.parse(body);
275
- 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
+ }
276
319
  }
277
320
  catch {
278
321
  msg = null;
@@ -298,6 +341,10 @@ function openHttp(url, specVersion) {
298
341
  /* notifications are best-effort */
299
342
  }
300
343
  },
344
+ onServerMessage(listener) {
345
+ serverListeners.add(listener);
346
+ return () => serverListeners.delete(listener);
347
+ },
301
348
  close() {
302
349
  /* nothing persistent to tear down */
303
350
  },
@@ -503,6 +550,161 @@ function dialectFinding(targetLabel, toolName, field, issue) {
503
550
  function targetLabel(target) {
504
551
  return target.kind === 'http' ? target.url : [target.command, ...target.args].join(' ');
505
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
+ }
506
708
  async function probeServer(target, opts) {
507
709
  const label = targetLabel(target);
508
710
  const findings = [];
@@ -573,6 +775,13 @@ async function probeServer(target, opts) {
573
775
  findings.push(dialectFinding(label, name, field, issue));
574
776
  }
575
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
+ }
576
785
  return {
577
786
  target: label,
578
787
  transport: target.kind,
package/dist/rules.js CHANGED
@@ -129,6 +129,55 @@ exports.RUNTIME_RULES = {
129
129
  after: "return { error: { code: -32602, message: 'Invalid params' } }; // was -32002 — the static scan's --fix rewrites source occurrences",
130
130
  docUrl: constants_1.SPEC_URL,
131
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
+ },
132
181
  };
133
182
  const CAP_RE = /capabilities/i;
134
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' | 'missing-server-discover' | 'legacy-resource-error-code';
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
@@ -18,4 +18,10 @@ exports.ALL_RUNTIME_RULE_IDS = [
18
18
  'requires-initialize-handshake',
19
19
  'missing-server-discover',
20
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',
21
27
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@booyaka/mcp-vet",
3
- "version": "0.6.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",