@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 +45 -0
- package/README.md +20 -0
- package/dist/cli.js +3 -1
- package/dist/probe-cli.js +11 -5
- package/dist/probe.d.ts +7 -0
- package/dist/probe.js +214 -5
- package/dist/rules.js +49 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +6 -0
- package/package.json +1 -1
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
|
-
|
|
84
|
-
|
|
84
|
+
// `--spec` is a shorthand for `--spec-version` that ALSO enables the extra
|
|
85
|
+
// compliance suite. When present it wins over --spec-version.
|
|
86
|
+
const specChecks = opts.spec !== undefined;
|
|
87
|
+
const rawSpec = opts.spec ?? opts.specVersion;
|
|
88
|
+
if (!types_1.SPEC_VERSIONS.includes(rawSpec)) {
|
|
89
|
+
const flag = opts.spec !== undefined ? '--spec' : '--spec-version';
|
|
90
|
+
fail(`invalid ${flag} "${rawSpec}". Valid: ${types_1.SPEC_VERSIONS.join(', ')}`);
|
|
85
91
|
}
|
|
86
|
-
const specVersion =
|
|
92
|
+
const specVersion = rawSpec;
|
|
87
93
|
const timeoutMs = Number(opts.timeout);
|
|
88
94
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
89
95
|
fail(`invalid --timeout "${opts.timeout}" (need a positive number of milliseconds).`);
|
|
@@ -111,7 +117,7 @@ async function runProbeCli(argv) {
|
|
|
111
117
|
}
|
|
112
118
|
let result;
|
|
113
119
|
try {
|
|
114
|
-
result = await (0, probe_1.probeServer)(target, { specVersion, timeoutMs });
|
|
120
|
+
result = await (0, probe_1.probeServer)(target, { specVersion, timeoutMs, specChecks });
|
|
115
121
|
}
|
|
116
122
|
catch (err) {
|
|
117
123
|
if (err instanceof probe_1.ProbeError)
|
package/dist/probe.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export interface ProbeOptions {
|
|
|
14
14
|
specVersion: SpecVersion;
|
|
15
15
|
/** per-request timeout in ms (also the stateless hang-detection window) */
|
|
16
16
|
timeoutMs: number;
|
|
17
|
+
/**
|
|
18
|
+
* Run the `--spec 2026-07-28` compliance suite in addition to the existing
|
|
19
|
+
* checks: stateless-no-session, stateless-no-init, required-headers, and the
|
|
20
|
+
* deprecated-sampling / deprecated-roots / deprecated-logging warnings. Only
|
|
21
|
+
* meaningful together with specVersion '2026-07-28'.
|
|
22
|
+
*/
|
|
23
|
+
specChecks?: boolean;
|
|
17
24
|
}
|
|
18
25
|
export interface ProbeResult {
|
|
19
26
|
/** human-readable target (the command line or the URL) */
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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