@booyaka/mcp-vet 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,87 @@ All notable changes to `mcp-vet` are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
5
5
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.0]
8
+
9
+ The full 2026-07-28 compliance suite — `mcp-vet probe --spec-version 2026-07-28`
10
+ now covers all three breaking changes the release candidate makes to server
11
+ behavior, not just the removed handshake.
12
+
13
+ ### Added
14
+
15
+ - **`missing-server-discover` (ERROR, `--spec-version 2026-07-28`)** — calls
16
+ the `server/discover` RPC that every 2026-07-28 server MUST implement
17
+ (SEP-2575; it replaces the removed initialize handshake for up-front
18
+ capability discovery) and flags a server whose answer is an error, a hang, or
19
+ a result missing the required `capabilities` key. The spec defines
20
+ `server/discover` as JSON-RPC only — 2026-07-28 removes the HTTP GET
21
+ endpoint, so no `GET /mcp/discover` variant is probed.
22
+ - **`legacy-resource-error-code` (ERROR, `--spec-version 2026-07-28`)** — reads
23
+ a deliberately nonexistent resource URI (`mcp-vet://probe/...`) and flags a
24
+ server that still answers `-32002` instead of the JSON-RPC standard `-32602`
25
+ (Invalid Params). Servers without `resources/read` (`-32601`) are skipped,
26
+ not flagged; unexpected codes are reported as inconclusive notes.
27
+ - **`mcp-vet run`** — an alias for `mcp-vet probe`.
28
+ - **`test/probe-fixtures/server-partial.mjs`** — a hybrid (handshake +
29
+ stateless) fixture with exactly one migration defect per mode
30
+ (`legacy-error-code` / `no-discover` / `bad-discover`), isolating each new
31
+ rule; 7 new tests (62 total), including proof that the new checks are gated
32
+ behind `--spec-version 2026-07-28` and the default probe is unchanged.
33
+
34
+ ### Changed
35
+
36
+ - The stateless first request now sends the RC's exact namespaced `_meta` key
37
+ `io.modelcontextprotocol/clientCapabilities` (was the incorrect
38
+ `io.modelcontextprotocol/capabilities`); the stateless fixtures now *require*
39
+ the namespaced keys, locking the wire format into the tests.
40
+ - The new checks run on whichever contact path succeeded (stateless or classic
41
+ fallback), so a handshake-only legacy server gets its complete 2026-07-28
42
+ migration report — handshake + discover findings — in a single probe.
43
+ - `ProbeResult` gains `discoverOk` and `errorCodeOk` verdict fields.
44
+
45
+ ## [0.5.0]
46
+
47
+ The runtime-probe release — `mcp-vet probe` connects to a *running* MCP server
48
+ (stdio command or Streamable HTTP URL) and detects the two 2026-07-28 violation
49
+ categories that only exist on the wire, not in source.
50
+
51
+ ### Added
52
+
53
+ - **`mcp-vet probe [options] <url | command...>`** — a runtime prober with two
54
+ new violation categories, reported in the same JSON + SARIF formats as the
55
+ static scan:
56
+ - **`json-schema-dialect` (WARN)** — calls `tools/list` and inspects every
57
+ tool's `inputSchema`/`outputSchema` (SEP-2106 lifts both to full JSON
58
+ Schema 2020-12). Flags an explicit draft-04/-06/-07 (or 2019-09) `$schema`
59
+ at high confidence, and — when `$schema` is absent — draft-only keyword
60
+ forms (`definitions`, `$ref: "#/definitions/…"`, boolean
61
+ `exclusiveMinimum`/`exclusiveMaximum`, array-form `items`, schema-form
62
+ `dependencies`) at medium confidence. The walker recurses only into schema
63
+ positions, so a *property* named `definitions` is never a false positive,
64
+ and an explicit 2020-12 `$schema` is trusted.
65
+ - **`requires-initialize-handshake` (ERROR)** — with
66
+ `--spec-version 2026-07-28`, makes a stateless first request (no
67
+ `initialize`; protocolVersion/clientInfo/capabilities travel in `_meta`
68
+ per the RC) and flags a server that rejects it or hangs. Cross-checked:
69
+ only emitted when the classic 2025-11-25 handshake path *does* work, so a
70
+ dead server is an operational error (exit 2), never a false violation.
71
+ - **`--spec-version <2025-11-25|2026-07-28>`** (default `2025-11-25`) selects
72
+ the revision to vet against; `--timeout <ms>` bounds each request and doubles
73
+ as the hang-detection window; `--json` / `--sarif [file]` / `--fail-on` /
74
+ `--quiet` / `--color` work as in the scan.
75
+ - **Runtime rules in SARIF** — probe rules join the driver metadata when they
76
+ fire (`ERROR` → `error`, `WARN` → `warning`); the static-scan SARIF keeps its
77
+ stable 9-rule shape.
78
+ - **`test/probe-fixtures/`** — minimal real MCP servers used by 18 new tests:
79
+ `server-draft07.mjs` (explicit + inferable draft-07 tools, a modern 2020-12
80
+ tool, and a property literally named `definitions`), `server-requires-init.mjs`
81
+ (rejects pre-initialize requests with `-32002`), `server-stateless.mjs`
82
+ (2026-07-28-native, requires `_meta`, no initialize), and `server-http.mjs`
83
+ (Streamable HTTP, sessionful *and* stateless modes).
84
+ - Verified against the official `@modelcontextprotocol/server-everything@2026.7.4`:
85
+ it answers stateless requests, but all of its tool schemas still declare
86
+ draft-07 — `probe` reports 14 true `json-schema-dialect` findings.
87
+
7
88
  ## [0.4.0]
8
89
 
9
90
  The community-feedback release — everything in it traces to reader comments on
package/README.md CHANGED
@@ -18,7 +18,7 @@ npx @booyaka/mcp-vet .
18
18
  <img src="https://raw.githubusercontent.com/Booyaka101/mcp-vet/main/assets/demo.png" alt="mcp-vet scanning a server — BREAKING and DEPRECATED findings with before/after fixes and confidence tags" width="720">
19
19
  </p>
20
20
 
21
- No account, no API key, no network calls — it parses your code locally (ts-morph for TS/JS, a bundled Python `ast` script for `.py`) and exits non-zero if it finds anything **BREAKING**, so you can drop it straight into CI.
21
+ No account, no API key — the scan parses your code locally (ts-morph for TS/JS, a bundled Python `ast` script for `.py`), makes no network calls, and exits non-zero if it finds anything **BREAKING**, so you can drop it straight into CI. (The opt-in [`mcp-vet probe`](#vet-a-running-server-mcp-vet-probe) is the one command that talks to a server — and only the one you point it at.)
22
22
 
23
23
  ## What actually happens on July 28
24
24
 
@@ -187,7 +187,7 @@ case 'tasks/list': return listTasks();
187
187
  - **The long-lived server→client SSE push channel is removed** — a server may only send requests to the client *while it is actively processing a client request*. Standing push streams / out-of-band notifications need rework.
188
188
  - **Streamable HTTP now requires `Mcp-Method` and `Mcp-Name` headers** that mirror the JSON-RPC body; servers must reject requests where headers and body disagree.
189
189
  - **Auth hardening** — validate the RFC 9207 `iss` parameter, declare OIDC `application_type` on Dynamic Client Registration, and bind tokens to the issuing authorization server.
190
- - **Tool schemas may now be full JSON Schema 2020-12** (`oneOf`/`anyOf`/`$ref`/conditionals); do not auto-dereference external `$ref` URIs.
190
+ - **Tool schemas may now be full JSON Schema 2020-12** (`oneOf`/`anyOf`/`$ref`/conditionals); do not auto-dereference external `$ref` URIs. The *dialect* half of this — schemas still declaring or using draft-07 forms — **is** detectable at runtime: [`mcp-vet probe`](#vet-a-running-server-mcp-vet-probe) checks it against your live server.
191
191
 
192
192
  The CLI prints a one-line reminder of these after every scan.
193
193
 
@@ -213,6 +213,65 @@ writes nine ready-to-fire JSON fixtures plus a `CHECKLIST.md`, covering the runt
213
213
 
214
214
  Each fixture is a plain JSON description (`send` headers + JSON-RPC body, `expect` notes) you can replay with curl, supertest, pytest + httpx, or any HTTP harness. The checklist also spells out the **dual-version rollout matrix** — run both `2025-11-25` and `2026-07-28` paths until your clients have all moved — and a **client-side assumptions** list (session resume, per-request `_meta`, retries landing on other instances, `tools/list` revalidation).
215
215
 
216
+ ## Vet a running server (`mcp-vet probe`)
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 2026-07-28 violations that only exist at runtime (`run` is an alias: `mcp-vet run …` ≡ `mcp-vet probe …`):
219
+
220
+ | ID | Severity | What it checks |
221
+ | --- | --- | --- |
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`, 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 |
226
+
227
+ ```bash
228
+ # vet the schemas of a stdio server (spawns the command; a lone .js file runs with Node)
229
+ npx @booyaka/mcp-vet probe node ./dist/server.js
230
+
231
+ # full 2026-07-28 readiness: stateless first contact + server/discover +
232
+ # resource error code + schema dialects
233
+ npx @booyaka/mcp-vet probe --spec-version 2026-07-28 http://localhost:3000/mcp
234
+ ```
235
+
236
+ ```text
237
+ mcp-vet probe — node ./dist/server.js · spec 2026-07-28 · stdio · 12 tool(s) listed
238
+ stateless probe: stateless tools/list was rejected: -32002 Server not initialized
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)
242
+
243
+ ERROR requires-initialize-handshake [high]
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 ...
247
+ WARN json-schema-dialect [high]
248
+ tool "echo" inputSchema: $schema = http://json-schema.org/draft-07/schema# (draft-07)
249
+ ```
250
+
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
+ 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
+
269
+ Try it against the official reference server — the July 2026 `@modelcontextprotocol/server-everything` (beta 2026-07-28 SDK) answers stateless requests and already returns the new `-32602` resource error code, but it does not implement `server/discover` yet and its tool schemas still declare draft-07 — `probe` reports exactly that (1 ERROR, 13 WARN):
270
+
271
+ ```bash
272
+ npx @booyaka/mcp-vet probe --spec-version 2026-07-28 npx -y @modelcontextprotocol/server-everything stdio
273
+ ```
274
+
216
275
  ## Usage
217
276
 
218
277
  ```bash
@@ -221,6 +280,7 @@ npx @booyaka/mcp-vet . --fix # scan, and auto-apply the mechanical -32
221
280
  npx @booyaka/mcp-vet ./src ./packages # multiple roots
222
281
  npx @booyaka/mcp-vet server.py # a single file
223
282
  npx @booyaka/mcp-vet fixtures ./dir # write runtime conformance fixtures + checklist (default: ./mcp-vet-fixtures)
283
+ npx @booyaka/mcp-vet probe <url|cmd> # vet a RUNNING server's wire behavior (see section above; alias: run)
224
284
  ```
225
285
 
226
286
  Globs `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and `**/*.py`, skipping `node_modules`, `.git`, `__pycache__`, `dist`, and `build`.
@@ -426,10 +486,10 @@ Also exported: `renderJson` / `renderMarkdown` / `renderSarif`, `RULES`, and the
426
486
  ```bash
427
487
  npm install # installs deps and builds (via prepare)
428
488
  npm run build # tsc -> dist/ + copies the Python script
429
- npm test # builds, then runs the Node.js built-in test runner (30 tests)
489
+ npm test # builds, then runs the Node.js built-in test runner (62 tests)
430
490
  ```
431
491
 
432
- 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).
492
+ Test fixtures live in `test/fixtures/` (dirty TS + Python servers, a `clean/` server with zero violations, `negatives/` true-negatives, a `confidence/` gradient, and `suppress/` cases). Runtime-probe fixtures live in `test/probe-fixtures/` — minimal stdio + Streamable-HTTP MCP servers: one returning draft-07 schemas, one requiring the initialize handshake, one fully migrated 2026-07-28-native (stateless + `server/discover` + `-32602`), and a `server-partial.mjs` with one deliberate migration defect per mode (`legacy-error-code` / `no-discover` / `bad-discover`).
433
493
 
434
494
  ## License
435
495
 
package/dist/autofix.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Finding, PatternId } from './types';
1
+ import { Finding, ViolationId } from './types';
2
2
  export interface FixPreview {
3
3
  file: string;
4
4
  line: number;
@@ -16,7 +16,7 @@ export interface FixOptions {
16
16
  /** Compute and return the rewrites without touching any files. */
17
17
  dryRun?: boolean;
18
18
  }
19
- export declare function isFixable(id: PatternId): boolean;
19
+ export declare function isFixable(id: ViolationId): boolean;
20
20
  /**
21
21
  * Apply the safe mechanical fixes in place. Returns which findings were fixed so
22
22
  * the caller can drop them from the report and the exit-code calculation.
package/dist/autofix.js CHANGED
@@ -110,9 +110,9 @@ function applyFixes(findings, opts = {}) {
110
110
  preview.push(...localPreview);
111
111
  continue;
112
112
  }
113
- // Only count/return findings as fixed once the write actually succeeds — a
113
+ // Only count/return findings as fixed once the write actually succeeds — a
114
114
  // failed write (read-only file, EACCES) must not report the code as fixed.
115
- const out = (hasBom ? '' : '') + lines.join('\n');
115
+ const out = (hasBom ? '' : '') + lines.join('\n');
116
116
  try {
117
117
  fs.writeFileSync(absPath, out, 'utf8');
118
118
  }
package/dist/cli.js CHANGED
@@ -45,6 +45,7 @@ const constants_1 = require("./constants");
45
45
  const reporters_1 = require("./reporters");
46
46
  const autofix_1 = require("./autofix");
47
47
  const conformance_1 = require("./conformance");
48
+ const probe_cli_1 = require("./probe-cli");
48
49
  const CONF_VALUES = ['high', 'medium', 'low'];
49
50
  const FAILON_VALUES = ['breaking', 'any', 'none'];
50
51
  function fail(msg) {
@@ -97,199 +98,218 @@ if (process.argv[2] === 'fixtures') {
97
98
  fail(`could not write fixtures: ${err.message}`);
98
99
  }
99
100
  }
100
- const program = new commander_1.Command();
101
- program
102
- .name('mcp-vet')
103
- .description('Scan MCP server source code for patterns that break under the 2026-07-28 MCP spec release candidate.')
104
- .argument('[paths...]', 'files or directories to scan', ['.'])
105
- .option('--github-annotations', 'emit GitHub Actions ::error/::warning annotations to stdout')
106
- .option('--sarif [file]', 'write a SARIF 2.1.0 report (default file: mcp-vet.sarif)')
107
- .option('--out-dir <dir>', 'directory for mcp-vet-report.md and mcp-vet-results.json', process.cwd())
108
- .option('--no-files', 'do not write the markdown/json report files')
109
- .option('--only <ids>', 'only run these pattern ids (comma/space separated)')
110
- .option('--disable <ids>', 'skip these pattern ids (comma/space separated)')
111
- .option('--fail-on <level>', `exit non-zero on: ${FAILON_VALUES.join(' | ')}`, 'breaking')
112
- .option('--min-confidence <level>', `report only findings at/above: ${CONF_VALUES.join(' | ')}`, 'low')
113
- .option('--ignore <glob>', 'ignore paths matching glob (repeatable)', (v, acc) => {
114
- acc.push(v);
115
- return acc;
116
- }, [])
117
- .option('--max-file-size <kb>', 'skip files larger than this many KB (0 = no limit)', '1536')
118
- .option('--no-py-fallback', 'disable the regex fallback when no Python interpreter is found')
119
- .option('--config <path>', 'path to a config file (.mcpvetrc.json)')
120
- .option('--fix', 'auto-apply the safe mechanical fixes in place (currently: -32002 → -32602)')
121
- .option('--dry-run', 'with --fix: print the rewrites that would be made, without changing files')
122
- .option('--json', 'print findings as a JSON array to stdout (implies a quiet terminal report)')
123
- .option('--color', 'force colored output')
124
- .option('--no-color', 'disable colored output')
125
- .option('--quiet', 'suppress the human-readable terminal report')
126
- .version((0, constants_1.getVersion)(), '-v, --version')
127
- .addHelpText('after', '\nCommands:\n fixtures [dir] write protocol-level conformance fixtures + CHECKLIST.md (default: ./mcp-vet-fixtures)')
128
- .showHelpAfterError();
129
- program.parse(process.argv);
130
- const opts = program.opts();
131
- const paths = program.args.length ? program.args : ['.'];
132
- // --- Resolve configuration (CLI over config file over defaults) ---
133
- let config = {};
134
- try {
135
- config = (0, config_1.loadConfig)(process.cwd(), opts.config);
101
+ // `mcp-vet probe [options] <url | command...>` — connect to a RUNNING server and
102
+ // vet its wire behavior (JSON Schema dialect, stateless-protocol readiness,
103
+ // server/discover, resource error code). `run` is an alias for `probe`.
104
+ // Async, so the scan pipeline only runs in the else-branch.
105
+ if (process.argv[2] === 'probe' || process.argv[2] === 'run') {
106
+ (0, probe_cli_1.runProbeCli)(process.argv.slice(3)).then((code) => process.exit(code), (err) => fail(err.message));
136
107
  }
137
- catch (err) {
138
- if (err instanceof config_1.ConfigError)
139
- fail(err.message);
140
- throw err;
108
+ else {
109
+ scanMain();
141
110
  }
142
- // Validate enums
143
- if (!FAILON_VALUES.includes(opts.failOn)) {
144
- fail(`invalid --fail-on "${opts.failOn}". Valid: ${FAILON_VALUES.join(', ')}`);
145
- }
146
- if (!CONF_VALUES.includes(opts.minConfidence)) {
147
- fail(`invalid --min-confidence "${opts.minConfidence}". Valid: ${CONF_VALUES.join(', ')}`);
148
- }
149
- // CLI value wins when explicitly set; otherwise fall back to the config file.
150
- const fromCli = (key) => program.getOptionValueSource(key) === 'cli';
151
- const failOn = fromCli('failOn')
152
- ? opts.failOn
153
- : config.failOn ?? opts.failOn;
154
- const minConfidence = fromCli('minConfidence')
155
- ? opts.minConfidence
156
- : config.minConfidence ?? opts.minConfidence;
157
- const normalizeIds = (ids) => ids
158
- ?.map((s) => String(s).toUpperCase())
159
- .filter((s) => types_1.ALL_PATTERN_IDS.includes(s));
160
- const cliOnly = parsePatternIds(opts.only);
161
- const cliDisable = parsePatternIds(opts.disable);
162
- const only = cliOnly ?? normalizeIds(config.only);
163
- const disable = cliDisable ?? normalizeIds(config.disable);
164
- let enabled = new Set(types_1.ALL_PATTERN_IDS);
165
- if (only && only.length)
166
- enabled = new Set(only.filter((id) => types_1.ALL_PATTERN_IDS.includes(id)));
167
- // `disable` always applies on top — so a CLI --disable still narrows a config `only`.
168
- if (disable && disable.length) {
169
- for (const id of disable)
170
- enabled.delete(id);
171
- }
172
- if (enabled.size === 0)
173
- fail('no rules enabled after applying --only/--disable.');
174
- const maxKbRaw = Number(opts.maxFileSize);
175
- if (!Number.isFinite(maxKbRaw) || maxKbRaw < 0)
176
- fail(`invalid --max-file-size "${opts.maxFileSize}".`);
177
- const maxFileSizeKb = fromCli('maxFileSize')
178
- ? maxKbRaw
179
- : config.maxFileSizeKb != null
180
- ? config.maxFileSizeKb
181
- : maxKbRaw;
182
- const pythonFallback = fromCli('pyFallback')
183
- ? opts.pyFallback
184
- : config.pythonFallback != null
185
- ? config.pythonFallback
186
- : opts.pyFallback;
187
- const color = fromCli('color') ? opts.color : undefined;
188
- // Ignore patterns: config + CLI + .mcpvetignore in cwd and each root dir
189
- const ignorePatterns = new Set([...(config.ignore ?? []), ...opts.ignore]);
190
- for (const line of readIgnoreFile(process.cwd()))
191
- ignorePatterns.add(line);
192
- for (const p of paths) {
111
+ function scanMain() {
112
+ const program = new commander_1.Command();
113
+ program
114
+ .name('mcp-vet')
115
+ .description('Scan MCP server source code for patterns that break under the 2026-07-28 MCP spec release candidate.')
116
+ .argument('[paths...]', 'files or directories to scan', ['.'])
117
+ .option('--github-annotations', 'emit GitHub Actions ::error/::warning annotations to stdout')
118
+ .option('--sarif [file]', 'write a SARIF 2.1.0 report (default file: mcp-vet.sarif)')
119
+ .option('--out-dir <dir>', 'directory for mcp-vet-report.md and mcp-vet-results.json', process.cwd())
120
+ .option('--no-files', 'do not write the markdown/json report files')
121
+ .option('--only <ids>', 'only run these pattern ids (comma/space separated)')
122
+ .option('--disable <ids>', 'skip these pattern ids (comma/space separated)')
123
+ .option('--fail-on <level>', `exit non-zero on: ${FAILON_VALUES.join(' | ')}`, 'breaking')
124
+ .option('--min-confidence <level>', `report only findings at/above: ${CONF_VALUES.join(' | ')}`, 'low')
125
+ .option('--ignore <glob>', 'ignore paths matching glob (repeatable)', (v, acc) => {
126
+ acc.push(v);
127
+ return acc;
128
+ }, [])
129
+ .option('--max-file-size <kb>', 'skip files larger than this many KB (0 = no limit)', '1536')
130
+ .option('--no-py-fallback', 'disable the regex fallback when no Python interpreter is found')
131
+ .option('--config <path>', 'path to a config file (.mcpvetrc.json)')
132
+ .option('--fix', 'auto-apply the safe mechanical fixes in place (currently: -32002 → -32602)')
133
+ .option('--dry-run', 'with --fix: print the rewrites that would be made, without changing files')
134
+ .option('--json', 'print findings as a JSON array to stdout (implies a quiet terminal report)')
135
+ .option('--color', 'force colored output')
136
+ .option('--no-color', 'disable colored output')
137
+ .option('--quiet', 'suppress the human-readable terminal report')
138
+ .version((0, constants_1.getVersion)(), '-v, --version')
139
+ .addHelpText('after', [
140
+ '',
141
+ 'Commands:',
142
+ ' fixtures [dir] write protocol-level conformance fixtures + CHECKLIST.md (default: ./mcp-vet-fixtures)',
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
+ ].join('\n'))
147
+ .showHelpAfterError();
148
+ program.parse(process.argv);
149
+ const opts = program.opts();
150
+ const paths = program.args.length ? program.args : ['.'];
151
+ // --- Resolve configuration (CLI over config file over defaults) ---
152
+ let config = {};
193
153
  try {
194
- const abs = path.resolve(p);
195
- if (fs.statSync(abs).isDirectory()) {
196
- for (const line of readIgnoreFile(abs))
197
- ignorePatterns.add(line);
154
+ config = (0, config_1.loadConfig)(process.cwd(), opts.config);
155
+ }
156
+ catch (err) {
157
+ if (err instanceof config_1.ConfigError)
158
+ fail(err.message);
159
+ throw err;
160
+ }
161
+ // Validate enums
162
+ if (!FAILON_VALUES.includes(opts.failOn)) {
163
+ fail(`invalid --fail-on "${opts.failOn}". Valid: ${FAILON_VALUES.join(', ')}`);
164
+ }
165
+ if (!CONF_VALUES.includes(opts.minConfidence)) {
166
+ fail(`invalid --min-confidence "${opts.minConfidence}". Valid: ${CONF_VALUES.join(', ')}`);
167
+ }
168
+ // CLI value wins when explicitly set; otherwise fall back to the config file.
169
+ const fromCli = (key) => program.getOptionValueSource(key) === 'cli';
170
+ const failOn = fromCli('failOn')
171
+ ? opts.failOn
172
+ : config.failOn ?? opts.failOn;
173
+ const minConfidence = fromCli('minConfidence')
174
+ ? opts.minConfidence
175
+ : config.minConfidence ?? opts.minConfidence;
176
+ const normalizeIds = (ids) => ids
177
+ ?.map((s) => String(s).toUpperCase())
178
+ .filter((s) => types_1.ALL_PATTERN_IDS.includes(s));
179
+ const cliOnly = parsePatternIds(opts.only);
180
+ const cliDisable = parsePatternIds(opts.disable);
181
+ const only = cliOnly ?? normalizeIds(config.only);
182
+ const disable = cliDisable ?? normalizeIds(config.disable);
183
+ let enabled = new Set(types_1.ALL_PATTERN_IDS);
184
+ if (only && only.length)
185
+ enabled = new Set(only.filter((id) => types_1.ALL_PATTERN_IDS.includes(id)));
186
+ // `disable` always applies on top — so a CLI --disable still narrows a config `only`.
187
+ if (disable && disable.length) {
188
+ for (const id of disable)
189
+ enabled.delete(id);
190
+ }
191
+ if (enabled.size === 0)
192
+ fail('no rules enabled after applying --only/--disable.');
193
+ const maxKbRaw = Number(opts.maxFileSize);
194
+ if (!Number.isFinite(maxKbRaw) || maxKbRaw < 0)
195
+ fail(`invalid --max-file-size "${opts.maxFileSize}".`);
196
+ const maxFileSizeKb = fromCli('maxFileSize')
197
+ ? maxKbRaw
198
+ : config.maxFileSizeKb != null
199
+ ? config.maxFileSizeKb
200
+ : maxKbRaw;
201
+ const pythonFallback = fromCli('pyFallback')
202
+ ? opts.pyFallback
203
+ : config.pythonFallback != null
204
+ ? config.pythonFallback
205
+ : opts.pyFallback;
206
+ const color = fromCli('color') ? opts.color : undefined;
207
+ // Ignore patterns: config + CLI + .mcpvetignore in cwd and each root dir
208
+ const ignorePatterns = new Set([...(config.ignore ?? []), ...opts.ignore]);
209
+ for (const line of readIgnoreFile(process.cwd()))
210
+ ignorePatterns.add(line);
211
+ for (const p of paths) {
212
+ try {
213
+ const abs = path.resolve(p);
214
+ if (fs.statSync(abs).isDirectory()) {
215
+ for (const line of readIgnoreFile(abs))
216
+ ignorePatterns.add(line);
217
+ }
218
+ }
219
+ catch {
220
+ /* validated later in scan() */
198
221
  }
199
222
  }
200
- catch {
201
- /* validated later in scan() */
223
+ const ignore = new ignore_1.IgnoreMatcher([...ignorePatterns]);
224
+ // --- Scan ---
225
+ let result;
226
+ try {
227
+ result = (0, scanner_1.scan)(paths, {
228
+ enabled,
229
+ ignore,
230
+ maxFileSizeKb,
231
+ pythonFallback,
232
+ minConfidence,
233
+ });
202
234
  }
203
- }
204
- const ignore = new ignore_1.IgnoreMatcher([...ignorePatterns]);
205
- // --- Scan ---
206
- let result;
207
- try {
208
- result = (0, scanner_1.scan)(paths, {
209
- enabled,
210
- ignore,
211
- maxFileSizeKb,
212
- pythonFallback,
213
- minConfidence,
214
- });
215
- }
216
- catch (err) {
217
- if (err instanceof scanner_1.ScanError)
218
- fail(err.message);
219
- throw err;
220
- }
221
- // --- Autofix (before reporting, so the report/exit reflect what remains) ---
222
- if (opts.fix) {
223
- const say = opts.json ? console.error : console.log; // keep stdout clean for --json
224
- const fr = (0, autofix_1.applyFixes)(result.findings, { dryRun: opts.dryRun });
225
- if (opts.dryRun) {
226
- if (fr.preview.length === 0) {
227
- say('mcp-vet: --fix --dry-run — nothing to auto-fix.');
235
+ catch (err) {
236
+ if (err instanceof scanner_1.ScanError)
237
+ fail(err.message);
238
+ throw err;
239
+ }
240
+ // --- Autofix (before reporting, so the report/exit reflect what remains) ---
241
+ if (opts.fix) {
242
+ const say = opts.json ? console.error : console.log; // keep stdout clean for --json
243
+ const fr = (0, autofix_1.applyFixes)(result.findings, { dryRun: opts.dryRun });
244
+ if (opts.dryRun) {
245
+ if (fr.preview.length === 0) {
246
+ say('mcp-vet: --fix --dry-run — nothing to auto-fix.');
247
+ }
248
+ else {
249
+ say(`mcp-vet: --fix --dry-run — ${fr.preview.length} rewrite(s) that would be applied (no files changed):`);
250
+ for (const p of fr.preview) {
251
+ say(` ${p.file}:${p.line}`);
252
+ say(` - ${p.before.trim()}`);
253
+ say(` + ${p.after.trim()}`);
254
+ }
255
+ }
228
256
  }
229
257
  else {
230
- say(`mcp-vet: --fix --dry-run — ${fr.preview.length} rewrite(s) that would be applied (no files changed):`);
231
- for (const p of fr.preview) {
232
- say(` ${p.file}:${p.line}`);
233
- say(` - ${p.before.trim()}`);
234
- say(` + ${p.after.trim()}`);
258
+ if (fr.fixedCount > 0) {
259
+ const fixed = new Set(fr.fixedFindings);
260
+ result.findings = result.findings.filter((f) => !fixed.has(f));
235
261
  }
262
+ say(fr.fixedCount > 0
263
+ ? `mcp-vet: fixed ${fr.fixedCount} occurrence(s) of -32002 → -32602 in ${fr.filesChanged.length} file(s).`
264
+ : 'mcp-vet: --fix found nothing to auto-fix.');
236
265
  }
237
266
  }
238
- else {
239
- if (fr.fixedCount > 0) {
240
- const fixed = new Set(fr.fixedFindings);
241
- result.findings = result.findings.filter((f) => !fixed.has(f));
242
- }
243
- say(fr.fixedCount > 0
244
- ? `mcp-vet: fixed ${fr.fixedCount} occurrence(s) of -32002 → -32602 in ${fr.filesChanged.length} file(s).`
245
- : 'mcp-vet: --fix found nothing to auto-fix.');
267
+ // --- Report ---
268
+ const quiet = opts.quiet || opts.json;
269
+ // Notices (Wrote ...) go to stderr in --json mode so stdout stays pure JSON.
270
+ const notify = (msg) => (opts.json ? console.error(msg) : console.log(msg));
271
+ if (opts.githubAnnotations) {
272
+ (0, reporters_1.printGithubAnnotations)(result.findings);
246
273
  }
247
- }
248
- // --- Report ---
249
- const quiet = opts.quiet || opts.json;
250
- // Notices (Wrote ...) go to stderr in --json mode so stdout stays pure JSON.
251
- const notify = (msg) => (opts.json ? console.error(msg) : console.log(msg));
252
- if (opts.githubAnnotations) {
253
- (0, reporters_1.printGithubAnnotations)(result.findings);
254
- }
255
- if (!quiet) {
256
- (0, reporters_1.reportTerminal)(result, { color });
257
- }
258
- if (opts.json) {
259
- process.stdout.write((0, reporters_1.renderJson)(result) + '\n');
260
- }
261
- if (opts.sarif) {
262
- const sarifPath = path.resolve(process.cwd(), typeof opts.sarif === 'string' ? opts.sarif : 'mcp-vet.sarif');
263
- try {
264
- (0, reporters_1.writeSarif)(result, sarifPath);
265
- if (!quiet)
266
- notify(`Wrote ${sarifPath}`);
274
+ if (!quiet) {
275
+ (0, reporters_1.reportTerminal)(result, { color });
267
276
  }
268
- catch (err) {
269
- console.error(`mcp-vet: failed to write SARIF: ${err.message}`);
277
+ if (opts.json) {
278
+ process.stdout.write((0, reporters_1.renderJson)(result) + '\n');
270
279
  }
271
- }
272
- if (opts.files) {
273
- try {
274
- const md = (0, reporters_1.writeMarkdown)(result, opts.outDir);
275
- const json = (0, reporters_1.writeJson)(result, opts.outDir);
276
- if (!quiet) {
277
- notify(`Wrote ${md}`);
278
- notify(`Wrote ${json}`);
280
+ if (opts.sarif) {
281
+ const sarifPath = path.resolve(process.cwd(), typeof opts.sarif === 'string' ? opts.sarif : 'mcp-vet.sarif');
282
+ try {
283
+ (0, reporters_1.writeSarif)(result, sarifPath);
284
+ if (!quiet)
285
+ notify(`Wrote ${sarifPath}`);
286
+ }
287
+ catch (err) {
288
+ console.error(`mcp-vet: failed to write SARIF: ${err.message}`);
279
289
  }
280
290
  }
281
- catch (err) {
282
- console.error(`mcp-vet: failed to write report files: ${err.message}`);
291
+ if (opts.files) {
292
+ try {
293
+ const md = (0, reporters_1.writeMarkdown)(result, opts.outDir);
294
+ const json = (0, reporters_1.writeJson)(result, opts.outDir);
295
+ if (!quiet) {
296
+ notify(`Wrote ${md}`);
297
+ notify(`Wrote ${json}`);
298
+ }
299
+ }
300
+ catch (err) {
301
+ console.error(`mcp-vet: failed to write report files: ${err.message}`);
302
+ }
283
303
  }
304
+ // --- Exit code ---
305
+ const hasBreaking = result.findings.some((f) => f.severity === 'BREAKING' || f.severity === 'ERROR');
306
+ const hasAny = result.findings.length > 0;
307
+ let failing = false;
308
+ if (failOn === 'breaking')
309
+ failing = hasBreaking;
310
+ else if (failOn === 'any')
311
+ failing = hasAny;
312
+ else
313
+ failing = false; // 'none'
314
+ process.exit(failing ? 1 : 0);
284
315
  }
285
- // --- Exit code ---
286
- const hasBreaking = result.findings.some((f) => f.severity === 'BREAKING');
287
- const hasAny = result.findings.length > 0;
288
- let failing = false;
289
- if (failOn === 'breaking')
290
- failing = hasBreaking;
291
- else if (failOn === 'any')
292
- failing = hasAny;
293
- else
294
- failing = false; // 'none'
295
- process.exit(failing ? 1 : 0);