@booyaka/mcp-vet 0.2.0 → 0.3.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,64 @@ 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.3.0]
8
+
9
+ The completeness release — full detection coverage, a real migration path, and a
10
+ programmatic API. Informed by a deep audit of the spec and the codebase.
11
+
12
+ ### Added
13
+
14
+ - **Two removed-method rules**: `TASKS_LIST_REMOVED` and `TASKS_RESULT_REMOVED`
15
+ (both BREAKING). `tasks/list` and `tasks/result` are removed on 2026-07-28
16
+ (SEP-2663); previously `tasks/list` was (incorrectly) treated as a non-issue.
17
+ - **Deprecated-capability *method* strings** — `roots/list`,
18
+ `notifications/roots/list_changed`, `sampling/createMessage`, `logging/setLevel`,
19
+ `notifications/message` are now flagged (SEP-2577 deprecates the methods, not just
20
+ the capability keys), catching servers that reference them without a literal
21
+ `capabilities` object nearby.
22
+ - **SDK schema-constant detection** — `server.setRequestHandler(InitializeRequestSchema, …)`
23
+ and friends are how real SDK servers register handlers; these are now mapped to the
24
+ right rule (previously only bare string literals matched — a major false negative).
25
+ - **SDK capability-constructor detection** — the Python SDK's
26
+ `ClientCapabilities(roots=RootsCapability())` is now recognized structurally (high
27
+ confidence, not proximity-medium), and `RootsCapability`/`SamplingCapability`/
28
+ `LoggingCapability` are matched directly. (Found by validating against the official
29
+ reference servers — a broad real-corpus run measured **0 false positives**.)
30
+ - **`--fix --dry-run`** — preview the rewrites `--fix` would make without touching files.
31
+ - **`sessionIdGenerator`** detection (value-aware) — flagged only when it is a real
32
+ generator, not the migrated `sessionIdGenerator: undefined`.
33
+ - **`--fix`** — auto-applies the safe mechanical rewrites in place (`-32002` →
34
+ `-32602`). Fixed findings are dropped from the report and the exit code.
35
+ - **`--json`** — prints findings as a JSON array to stdout (pure JSON; notices go to
36
+ stderr), for piping in CI.
37
+ - **Programmatic API** — the package now exposes `main`/`types`/`exports` with
38
+ bundled `.d.ts`: `scan`, `applyFixes`, `renderJson`/`renderMarkdown`/`renderSarif`,
39
+ `RULES`, and the public types.
40
+ - **"Needs manual review" awareness** — changes that can't be found statically (SSE
41
+ push-channel removal, required `Mcp-Method`/`Mcp-Name` headers, auth hardening,
42
+ JSON Schema 2020-12 schemas) are documented and the CLI prints a reminder after
43
+ every scan.
44
+ - Config JSON Schema (`schema/mcpvetrc.schema.json`), a tag-triggered `release.yml`
45
+ with npm provenance, and README husky/pre-commit examples.
46
+
47
+ ### Fixed
48
+
49
+ - **`--fix` file corruption on Python (multibyte lines)** — the bundled `ast`
50
+ scanner emitted UTF-8 *byte* column offsets; on a line with non-ASCII characters
51
+ autofix could rewrite an unrelated `-32002` inside a string. Columns are now
52
+ character-accurate, and autofix requires an exact column match (no blind
53
+ `indexOf` fallback).
54
+ - **`--fix` false success** — findings were marked fixed even when the file write
55
+ failed; they are now only cleared after a successful write.
56
+ - **Config precedence** — a config-file `only` no longer silently cancels a CLI
57
+ `--disable`; `--disable` always applies on top.
58
+ - Negative numeric literals are anchored consistently at the `-` across the TS and
59
+ Python analyzers (correct SARIF regions and autofix positions).
60
+
61
+ ### Changed
62
+
63
+ - Rule count is now 9 (was 7); SARIF advertises all 9 rules.
64
+
7
65
  ## [0.2.0]
8
66
 
9
67
  The robustness and precision release. Everything below is covered by the test suite.
@@ -50,5 +108,6 @@ Initial release.
50
108
  shared rule engine, and four report formats (terminal, Markdown, JSON, GitHub
51
109
  Actions annotations).
52
110
 
111
+ [0.3.0]: https://github.com/Booyaka101/mcp-vet/releases/tag/v0.3.0
53
112
  [0.2.0]: https://github.com/Booyaka101/mcp-vet/releases/tag/v0.2.0
54
113
  [0.1.0]: https://github.com/Booyaka101/mcp-vet/releases/tag/v0.1.0
package/README.md CHANGED
@@ -14,6 +14,10 @@
14
14
  npx @booyaka/mcp-vet .
15
15
  ```
16
16
 
17
+ <p align="center">
18
+ <img src="https://raw.githubusercontent.com/Booyaka101/mcp-vet/main/assets/demo.svg" alt="mcp-vet scanning a server — BREAKING and DEPRECATED findings with before/after fixes and confidence tags" width="720">
19
+ </p>
20
+
17
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.
18
22
 
19
23
  ---
@@ -25,17 +29,21 @@ Pointed at the [official MCP TypeScript SDK's own example servers](https://githu
25
29
  ```text
26
30
  legacy-routing.ts:36:29 BREAKING MCP_SESSION_ID [high]
27
31
  const sid = req.headers['mcp-session-id'] as string | undefined;
32
+ legacy-routing.ts:41:13 BREAKING MCP_SESSION_ID [medium]
33
+ sessionIdGenerator: () => randomUUID(),
28
34
  legacy-routing.ts:70:26 BREAKING MCP_SESSION_ID [high]
29
35
  exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', ...]
30
36
  sse-polling.ts:34:29 DEPRECATED LOGGING_CAP [high]
31
37
  capabilities: { logging: {} }
32
38
  sse-polling.ts:102:29 BREAKING MCP_SESSION_ID [high]
33
39
  const sid = req.headers['mcp-session-id'] as string | undefined;
40
+ sse-polling.ts:107:13 BREAKING MCP_SESSION_ID [medium]
41
+ sessionIdGenerator: () => randomUUID(),
34
42
 
35
- 4 finding(s): 3 BREAKING, 1 DEPRECATED
43
+ 6 finding(s): 5 BREAKING, 1 DEPRECATED
36
44
  ```
37
45
 
38
- And it stays quiet where it should the `initialize` mentioned in a *comment* in `dual-era.ts`, and the `sampling/createMessage` **method** in `sampling.ts`, are not flagged, because only the `sampling` **capability declaration** is deprecated, not the method. That precision (structural AST checks, not text matching) is what keeps the noise down on a real codebase.
46
+ Note it catches the `sessionIdGenerator` session usage — the real signal in SDK-based servers, which usually never write the literal `Mcp-Session-Id` string. And it stays quiet where it should: the `Mcp-Session-Id` mentioned in a *comment*, the `initialize` in a comment in `dual-era.ts`, and the `sampling/createMessage` in `sampling.ts` (which appears only in comments and behind the `requestSampling()` helper) are all left alone. That precision structural AST checks, not text matching is what keeps the noise down on a real codebase: **6 findings, 0 false positives.**
39
47
 
40
48
  ---
41
49
 
@@ -49,6 +57,8 @@ And it stays quiet where it should — the `initialize` mentioned in a *comment*
49
57
  | `INITIALIZE_HANDLER` | `initialize` / `notifications/initialized` handler registration |
50
58
  | `ERROR_CODE_32002` | the numeric error code `-32002` |
51
59
  | `TASKS_LEGACY` | `tasks/get` · `tasks/update` · `tasks/cancel` legacy method strings |
60
+ | `TASKS_LIST_REMOVED` | `tasks/list` — removed entirely (no replacement listing method) |
61
+ | `TASKS_RESULT_REMOVED` | `tasks/result` — removed; poll with `tasks/get` instead (SEP-2663) |
52
62
 
53
63
  ### 🟡 DEPRECATED (warns only — exit code 0, 12-month grace period)
54
64
 
@@ -113,6 +123,8 @@ return { error: { code: -32002, message: 'Resource not found' } };
113
123
  return { error: { code: -32602, message: 'Invalid params' } };
114
124
  ```
115
125
 
126
+ This one is purely mechanical, so `mcp-vet --fix` rewrites it for you in place.
127
+
116
128
  ### 4. Legacy Tasks methods — redesigned to a handle-based lifecycle
117
129
 
118
130
  > *"A server can answer `tools/call` with a task handle, and the client drives it with `tasks/get`, `tasks/update`, and `tasks/cancel`. Anyone who shipped against the `2025-11-25` experimental Tasks API will need to migrate to the new lifecycle."*
@@ -130,12 +142,36 @@ switch (method) {
130
142
  // the 2026-07-28 schema.
131
143
  ```
132
144
 
145
+ ### 5. `tasks/list` — removed entirely
146
+
147
+ > *"The `tasks/list` method is removed — it was unsafe once protocol-level sessions were gone. There is no replacement listing method."*
148
+
149
+ ```ts
150
+ // ❌ before
151
+ case 'tasks/list': return listTasks();
152
+
153
+ // ✅ after — there is nothing to enumerate server-side. A client tracks the
154
+ // task handles it got back from its own tools/call responses.
155
+ ```
156
+
133
157
  ---
134
158
 
159
+ ## Needs manual review (not statically detectable)
160
+
161
+ `mcp-vet` catches every 2026-07-28 change that has a concrete code-level signal (a header, a method string, an error code, a capability key). A few changes are real but **can't be found reliably by static analysis** — they're architectural or depend on runtime wiring. A clean scan is not a promise that these are handled, so check them by hand:
162
+
163
+ - **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.
164
+ - **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.
165
+ - **Auth hardening** — validate the RFC 9207 `iss` parameter, declare OIDC `application_type` on Dynamic Client Registration, and bind tokens to the issuing authorization server.
166
+ - **Tool schemas may now be full JSON Schema 2020-12** (`oneOf`/`anyOf`/`$ref`/conditionals); do not auto-dereference external `$ref` URIs.
167
+
168
+ The CLI prints a one-line reminder of these after every scan.
169
+
135
170
  ## Usage
136
171
 
137
172
  ```bash
138
173
  npx @booyaka/mcp-vet [paths...] # scan directories and/or files (default: current directory)
174
+ npx @booyaka/mcp-vet . --fix # scan, and auto-apply the mechanical -32002 → -32602 rewrite
139
175
  npx @booyaka/mcp-vet ./src ./packages # multiple roots
140
176
  npx @booyaka/mcp-vet server.py # a single file
141
177
  ```
@@ -153,6 +189,9 @@ Globs `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and `**/*.py`, skipping `node_modul
153
189
  | `--only <ids>` | only run these pattern ids (comma/space separated) |
154
190
  | `--disable <ids>` | skip these pattern ids |
155
191
  | `--fail-on <level>` | non-zero exit on `breaking` (default), `any`, or `none` |
192
+ | `--fix` | auto-apply the safe mechanical fixes in place (currently `-32002` → `-32602`) |
193
+ | `--dry-run` | with `--fix`: print the rewrites that would be made, without changing files |
194
+ | `--json` | print findings as a JSON array to stdout (pure JSON — notices go to stderr) |
156
195
  | `--min-confidence <level>` | report only findings at/above `high`, `medium`, or `low` (default) |
157
196
  | `--ignore <glob>` | ignore paths matching a gitignore-style glob (repeatable) |
158
197
  | `--max-file-size <kb>` | skip files larger than N KB (default 1536; `0` = no limit) |
@@ -237,13 +276,88 @@ To upload results to GitHub code scanning instead:
237
276
  with: { sarif_file: mcp-vet.sarif }
238
277
  ```
239
278
 
279
+ ### Local git hooks
280
+
281
+ Catch it before it reaches CI. With [husky](https://typicode.github.io/husky/) + [lint-staged](https://github.com/lint-staged/lint-staged):
282
+
283
+ ```json
284
+ // package.json
285
+ {
286
+ "lint-staged": {
287
+ "*.{ts,tsx,js,jsx,mjs,cjs,py}": "mcp-vet"
288
+ }
289
+ }
290
+ ```
291
+
292
+ Or with [pre-commit](https://pre-commit.com) (Python projects):
293
+
294
+ ```yaml
295
+ # .pre-commit-config.yaml
296
+ repos:
297
+ - repo: local
298
+ hooks:
299
+ - id: mcp-vet
300
+ name: mcp-vet
301
+ entry: npx @booyaka/mcp-vet
302
+ language: system
303
+ files: \.(ts|tsx|js|jsx|mjs|cjs|py)$
304
+ ```
305
+
306
+ ### Why there's no `--baseline`
307
+
308
+ Some linters let you "grandfather" existing findings so CI stays green. `mcp-vet` deliberately doesn't: this is a **one-time migration to a spec that ships on a fixed date**, and a suppressed finding is code that will break on July 28. The point is for the build to fail until it's actually fixed. For the rare intentional exception, use targeted [inline suppression](#suppressing-findings-inline) — an explicit, reviewable, per-line decision.
309
+
310
+ ### Large repositories
311
+
312
+ `mcp-vet` skips `node_modules`, `.git`, `dist`, `build`, and `__pycache__` by default, chunks the Python subprocess, and takes `--max-file-size`. On a big monorepo, scope the scan to the packages that ship MCP servers (`mcp-vet ./packages/server ./services/mcp`) and add `--ignore` globs for generated code.
313
+
240
314
  ---
241
315
 
242
316
  ## How it works
243
317
 
244
318
  - **TypeScript / JavaScript** — parsed with [`ts-morph`](https://ts-morph.com); the analyzer walks the AST and emits normalized tokens (string literals, signed numeric literals, identifiers, object keys) annotated with structural capability context and registration context.
245
- - **Python** — a bundled script (`dist/python/mcp_ast_scan.py`) runs `ast.parse` + a context-tracking walk in a subprocess (chunked for large repos) and emits the same token shape. When no interpreter exists, a regex fallback covers the deterministic rules.
246
- - A single rule engine applies all 7 rules to those tokens, so TS and Python behave identically. Findings are de-duplicated per (line, column, rule) and can be suppressed inline.
319
+ - **Python** — a bundled script (`dist/python/mcp_ast_scan.py`) runs `ast.parse` + a context-tracking walk in a subprocess (chunked for large repos) and emits the same token shape (with character-accurate columns). When no interpreter exists, a regex fallback covers the deterministic rules.
320
+ - A single rule engine applies all 9 rules to those tokens, so TS and Python behave identically. Findings are de-duplicated per (line, column, rule) and can be suppressed inline.
321
+
322
+ It matches the ways real servers are actually written, not just raw method strings:
323
+
324
+ - **literal method strings** — `'tasks/list'`, `'sampling/createMessage'`, `'logging/setLevel'`, …
325
+ - **SDK schema-constant registration** — `server.setRequestHandler(InitializeRequestSchema, …)` (how the official SDKs register handlers) maps `InitializeRequestSchema`, `ListRootsRequestSchema`, `CreateMessageRequestSchema`, `SetLevelRequestSchema`, `ListTasksRequestSchema`, `GetTaskResultRequestSchema`, … to the right rule.
326
+ - **SDK capability constructors** — the Python SDK's `ClientCapabilities(roots=RootsCapability())` is recognized structurally (high confidence), and `RootsCapability` / `SamplingCapability` / `LoggingCapability` are matched directly.
327
+ - **`sessionIdGenerator`** — flagged only when it's a real generator, not the migrated `sessionIdGenerator: undefined`.
328
+
329
+ Validated against a broad corpus of real MCP servers (the official reference servers, TS + Python): **0 false positives**.
330
+
331
+ ### Known limitations
332
+
333
+ - **Python SDK decorator/method registration** — a handler wired purely as `@server.list_roots()` or a bare `session.list_roots()` call (with no capability declaration or method string in the file) is not matched, to avoid false positives on generic method names. The capability declaration in the same server is normally caught.
334
+ - **Split/computed method strings** — `"tasks" + "/list"` or `f"tasks/{x}"` are not reconstructed.
335
+ - The **regex fallback** (no Python interpreter) covers only the deterministic rules at reduced precision; install Python for full `.py` fidelity.
336
+
337
+ ## Programmatic API
338
+
339
+ The scanner is usable as a library (typed) as well as a CLI — for editor extensions, custom CI steps, or migration harnesses:
340
+
341
+ ```ts
342
+ import { scan, ALL_PATTERN_IDS, IgnoreMatcher, applyFixes } from '@booyaka/mcp-vet';
343
+
344
+ const result = scan(['./src'], {
345
+ enabled: new Set(ALL_PATTERN_IDS),
346
+ ignore: new IgnoreMatcher([]),
347
+ maxFileSizeKb: 0,
348
+ pythonFallback: true,
349
+ minConfidence: 'low',
350
+ });
351
+
352
+ for (const f of result.findings) {
353
+ console.log(`${f.file}:${f.line} ${f.severity} ${f.patternId}`);
354
+ }
355
+
356
+ // Apply the safe mechanical fixes:
357
+ applyFixes(result.findings);
358
+ ```
359
+
360
+ Also exported: `renderJson` / `renderMarkdown` / `renderSarif`, `RULES`, and the `Finding` / `PatternId` / `Severity` / `Confidence` types.
247
361
 
248
362
  ## Requirements
249
363
 
@@ -255,7 +369,7 @@ To upload results to GitHub code scanning instead:
255
369
  ```bash
256
370
  npm install # installs deps and builds (via prepare)
257
371
  npm run build # tsc -> dist/ + copies the Python script
258
- npm test # builds, then runs the Node.js built-in test runner (18 tests)
372
+ npm test # builds, then runs the Node.js built-in test runner (30 tests)
259
373
  ```
260
374
 
261
375
  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).
@@ -0,0 +1,26 @@
1
+ import { Finding, PatternId } from './types';
2
+ export interface FixPreview {
3
+ file: string;
4
+ line: number;
5
+ before: string;
6
+ after: string;
7
+ }
8
+ export interface FixResult {
9
+ fixedCount: number;
10
+ filesChanged: string[];
11
+ fixedFindings: Finding[];
12
+ /** Every planned rewrite (populated in both real and dry-run modes). */
13
+ preview: FixPreview[];
14
+ }
15
+ export interface FixOptions {
16
+ /** Compute and return the rewrites without touching any files. */
17
+ dryRun?: boolean;
18
+ }
19
+ export declare function isFixable(id: PatternId): boolean;
20
+ /**
21
+ * Apply the safe mechanical fixes in place. Returns which findings were fixed so
22
+ * the caller can drop them from the report and the exit-code calculation.
23
+ * Replacements are same-length, so multiple fixes on one line never shift each
24
+ * other's positions.
25
+ */
26
+ export declare function applyFixes(findings: Finding[], opts?: FixOptions): FixResult;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isFixable = isFixable;
37
+ exports.applyFixes = applyFixes;
38
+ const fs = __importStar(require("node:fs"));
39
+ /**
40
+ * Rules whose fix is a safe, purely mechanical text substitution. Only the
41
+ * resource-not-found error code qualifies: -32002 -> -32602 is a same-length
42
+ * swap with no semantic ambiguity. Everything else (removed handshake, removed
43
+ * sessions, tasks/list removal, capability deprecations) requires human
44
+ * judgement and is deliberately NOT auto-fixed.
45
+ */
46
+ const FIXABLE = new Set(['ERROR_CODE_32002']);
47
+ function isFixable(id) {
48
+ return FIXABLE.has(id);
49
+ }
50
+ /**
51
+ * Apply the safe mechanical fixes in place. Returns which findings were fixed so
52
+ * the caller can drop them from the report and the exit-code calculation.
53
+ * Replacements are same-length, so multiple fixes on one line never shift each
54
+ * other's positions.
55
+ */
56
+ function applyFixes(findings, opts = {}) {
57
+ const byFile = new Map();
58
+ for (const f of findings) {
59
+ if (!isFixable(f.patternId) || !f.absPath)
60
+ continue;
61
+ const list = byFile.get(f.absPath) ?? [];
62
+ list.push(f);
63
+ byFile.set(f.absPath, list);
64
+ }
65
+ const filesChanged = [];
66
+ const fixedFindings = [];
67
+ const preview = [];
68
+ let fixedCount = 0;
69
+ for (const [absPath, fileFindings] of byFile) {
70
+ let raw;
71
+ try {
72
+ raw = fs.readFileSync(absPath, 'utf8');
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ const hasBom = raw.charCodeAt(0) === 0xfeff;
78
+ const text = hasBom ? raw.slice(1) : raw;
79
+ // Split on \n only; any \r stays attached to the line content, so CRLF
80
+ // endings are preserved verbatim on rejoin.
81
+ const lines = text.split('\n');
82
+ const applied = [];
83
+ const localPreview = [];
84
+ for (const f of fileFindings) {
85
+ const idx = f.line - 1;
86
+ if (idx < 0 || idx >= lines.length)
87
+ continue;
88
+ const L = lines[idx];
89
+ const col = (f.column ?? 0) - 1; // anchored at the '-' (both analyzers), else the first digit
90
+ // Require an exact column match. We deliberately do NOT fall back to a
91
+ // blind indexOf: a mislocated column (e.g. a skewed offset) could otherwise
92
+ // rewrite an unrelated `-32002` inside a string or comment and corrupt it.
93
+ let next = null;
94
+ if (col >= 0 && L.startsWith('-32002', col)) {
95
+ next = L.slice(0, col) + '-32602' + L.slice(col + 6);
96
+ }
97
+ else if (col >= 0 && L.startsWith('32002', col)) {
98
+ next = L.slice(0, col) + '32602' + L.slice(col + 5);
99
+ }
100
+ if (next !== null && next !== L) {
101
+ localPreview.push({ file: f.file, line: f.line, before: L.replace(/\r$/, ''), after: next.replace(/\r$/, '') });
102
+ lines[idx] = next;
103
+ applied.push(f);
104
+ }
105
+ }
106
+ if (applied.length === 0)
107
+ continue;
108
+ if (opts.dryRun) {
109
+ // Report what WOULD change; touch nothing.
110
+ preview.push(...localPreview);
111
+ continue;
112
+ }
113
+ // Only count/return findings as fixed once the write actually succeeds — a
114
+ // failed write (read-only file, EACCES) must not report the code as fixed.
115
+ const out = (hasBom ? '' : '') + lines.join('\n');
116
+ try {
117
+ fs.writeFileSync(absPath, out, 'utf8');
118
+ }
119
+ catch {
120
+ continue; // nothing fixed for this file
121
+ }
122
+ filesChanged.push(absPath);
123
+ preview.push(...localPreview);
124
+ for (const f of applied) {
125
+ fixedFindings.push(f);
126
+ fixedCount++;
127
+ }
128
+ }
129
+ return { fixedCount, filesChanged, fixedFindings, preview };
130
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js CHANGED
@@ -43,6 +43,7 @@ const config_1 = require("./config");
43
43
  const types_1 = require("./types");
44
44
  const constants_1 = require("./constants");
45
45
  const reporters_1 = require("./reporters");
46
+ const autofix_1 = require("./autofix");
46
47
  const CONF_VALUES = ['high', 'medium', 'low'];
47
48
  const FAILON_VALUES = ['breaking', 'any', 'none'];
48
49
  function fail(msg) {
@@ -98,6 +99,9 @@ program
98
99
  .option('--max-file-size <kb>', 'skip files larger than this many KB (0 = no limit)', '1536')
99
100
  .option('--no-py-fallback', 'disable the regex fallback when no Python interpreter is found')
100
101
  .option('--config <path>', 'path to a config file (.mcpvetrc.json)')
102
+ .option('--fix', 'auto-apply the safe mechanical fixes in place (currently: -32002 → -32602)')
103
+ .option('--dry-run', 'with --fix: print the rewrites that would be made, without changing files')
104
+ .option('--json', 'print findings as a JSON array to stdout (implies a quiet terminal report)')
101
105
  .option('--color', 'force colored output')
102
106
  .option('--no-color', 'disable colored output')
103
107
  .option('--quiet', 'suppress the human-readable terminal report')
@@ -141,7 +145,8 @@ const disable = cliDisable ?? normalizeIds(config.disable);
141
145
  let enabled = new Set(types_1.ALL_PATTERN_IDS);
142
146
  if (only && only.length)
143
147
  enabled = new Set(only.filter((id) => types_1.ALL_PATTERN_IDS.includes(id)));
144
- else if (disable && disable.length) {
148
+ // `disable` always applies on top — so a CLI --disable still narrows a config `only`.
149
+ if (disable && disable.length) {
145
150
  for (const id of disable)
146
151
  enabled.delete(id);
147
152
  }
@@ -194,19 +199,52 @@ catch (err) {
194
199
  fail(err.message);
195
200
  throw err;
196
201
  }
202
+ // --- Autofix (before reporting, so the report/exit reflect what remains) ---
203
+ if (opts.fix) {
204
+ const say = opts.json ? console.error : console.log; // keep stdout clean for --json
205
+ const fr = (0, autofix_1.applyFixes)(result.findings, { dryRun: opts.dryRun });
206
+ if (opts.dryRun) {
207
+ if (fr.preview.length === 0) {
208
+ say('mcp-vet: --fix --dry-run — nothing to auto-fix.');
209
+ }
210
+ else {
211
+ say(`mcp-vet: --fix --dry-run — ${fr.preview.length} rewrite(s) that would be applied (no files changed):`);
212
+ for (const p of fr.preview) {
213
+ say(` ${p.file}:${p.line}`);
214
+ say(` - ${p.before.trim()}`);
215
+ say(` + ${p.after.trim()}`);
216
+ }
217
+ }
218
+ }
219
+ else {
220
+ if (fr.fixedCount > 0) {
221
+ const fixed = new Set(fr.fixedFindings);
222
+ result.findings = result.findings.filter((f) => !fixed.has(f));
223
+ }
224
+ say(fr.fixedCount > 0
225
+ ? `mcp-vet: fixed ${fr.fixedCount} occurrence(s) of -32002 → -32602 in ${fr.filesChanged.length} file(s).`
226
+ : 'mcp-vet: --fix found nothing to auto-fix.');
227
+ }
228
+ }
197
229
  // --- Report ---
230
+ const quiet = opts.quiet || opts.json;
231
+ // Notices (Wrote ...) go to stderr in --json mode so stdout stays pure JSON.
232
+ const notify = (msg) => (opts.json ? console.error(msg) : console.log(msg));
198
233
  if (opts.githubAnnotations) {
199
234
  (0, reporters_1.printGithubAnnotations)(result.findings);
200
235
  }
201
- if (!opts.quiet) {
236
+ if (!quiet) {
202
237
  (0, reporters_1.reportTerminal)(result, { color });
203
238
  }
239
+ if (opts.json) {
240
+ process.stdout.write((0, reporters_1.renderJson)(result) + '\n');
241
+ }
204
242
  if (opts.sarif) {
205
243
  const sarifPath = path.resolve(process.cwd(), typeof opts.sarif === 'string' ? opts.sarif : 'mcp-vet.sarif');
206
244
  try {
207
245
  (0, reporters_1.writeSarif)(result, sarifPath);
208
- if (!opts.quiet)
209
- console.log(`Wrote ${sarifPath}`);
246
+ if (!quiet)
247
+ notify(`Wrote ${sarifPath}`);
210
248
  }
211
249
  catch (err) {
212
250
  console.error(`mcp-vet: failed to write SARIF: ${err.message}`);
@@ -216,9 +254,9 @@ if (opts.files) {
216
254
  try {
217
255
  const md = (0, reporters_1.writeMarkdown)(result, opts.outDir);
218
256
  const json = (0, reporters_1.writeJson)(result, opts.outDir);
219
- if (!opts.quiet) {
220
- console.log(`Wrote ${md}`);
221
- console.log(`Wrote ${json}`);
257
+ if (!quiet) {
258
+ notify(`Wrote ${md}`);
259
+ notify(`Wrote ${json}`);
222
260
  }
223
261
  }
224
262
  catch (err) {
@@ -0,0 +1,19 @@
1
+ import { PatternId, Confidence } from './types';
2
+ export type FailOn = 'breaking' | 'any' | 'none';
3
+ export interface Config {
4
+ ignore?: string[];
5
+ only?: PatternId[];
6
+ disable?: PatternId[];
7
+ failOn?: FailOn;
8
+ minConfidence?: Confidence;
9
+ maxFileSizeKb?: number;
10
+ pythonFallback?: boolean;
11
+ }
12
+ export declare class ConfigError extends Error {
13
+ }
14
+ /**
15
+ * Load config. If `explicitPath` is given it must exist and parse (throws
16
+ * ConfigError otherwise). Otherwise the first known config file found in `cwd`
17
+ * is used; missing config is not an error.
18
+ */
19
+ export declare function loadConfig(cwd: string, explicitPath?: string): Config;
@@ -0,0 +1,11 @@
1
+ export declare const SPEC_DATE = "July 28, 2026";
2
+ export declare const SPEC_URL = "https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/";
3
+ export declare const CHANGELOG_URL = "https://tokenmix.ai/blog/mcp-updates-changelog-every-protocol-change-2026";
4
+ /**
5
+ * 2026-07-28 changes that are real but NOT reliably detectable by static token
6
+ * analysis — surfaced to the user so the tool is honest about its scope rather
7
+ * than implying "clean === fully migrated".
8
+ */
9
+ export declare const MANUAL_REVIEW: string[];
10
+ /** Resolve the package version from package.json, tolerating layout differences. */
11
+ export declare function getVersion(): string;
package/dist/constants.js CHANGED
@@ -33,13 +33,24 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CHANGELOG_URL = exports.SPEC_URL = exports.SPEC_DATE = void 0;
36
+ exports.MANUAL_REVIEW = exports.CHANGELOG_URL = exports.SPEC_URL = exports.SPEC_DATE = void 0;
37
37
  exports.getVersion = getVersion;
38
38
  const fs = __importStar(require("node:fs"));
39
39
  const path = __importStar(require("node:path"));
40
40
  exports.SPEC_DATE = 'July 28, 2026';
41
41
  exports.SPEC_URL = 'https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/';
42
42
  exports.CHANGELOG_URL = 'https://tokenmix.ai/blog/mcp-updates-changelog-every-protocol-change-2026';
43
+ /**
44
+ * 2026-07-28 changes that are real but NOT reliably detectable by static token
45
+ * analysis — surfaced to the user so the tool is honest about its scope rather
46
+ * than implying "clean === fully migrated".
47
+ */
48
+ exports.MANUAL_REVIEW = [
49
+ 'the long-lived server→client SSE push channel is removed (a server may only send requests while handling one)',
50
+ 'Streamable HTTP now requires Mcp-Method and Mcp-Name headers that mirror the JSON-RPC body',
51
+ 'auth hardening: validate the RFC 9207 `iss` param, send OIDC `application_type`, bind tokens to the issuer',
52
+ 'tool inputSchema/outputSchema may now be full JSON Schema 2020-12 (do not auto-dereference external $ref)',
53
+ ];
43
54
  /** Resolve the package version from package.json, tolerating layout differences. */
44
55
  function getVersion() {
45
56
  const candidates = [
@@ -0,0 +1,6 @@
1
+ export declare class IgnoreMatcher {
2
+ private readonly regexes;
3
+ constructor(patterns: string[]);
4
+ get size(): number;
5
+ matches(relPosixPath: string): boolean;
6
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Programmatic API for mcp-vet.
3
+ *
4
+ * The CLI (`dist/cli.js`) is the primary entry point, but the scanner and its
5
+ * reporters are usable as a library — e.g. from an editor extension, a custom CI
6
+ * step, or a migration harness:
7
+ *
8
+ * ```ts
9
+ * import { scan, renderJson, applyFixes } from '@booyaka/mcp-vet';
10
+ * const result = scan(['./src'], {
11
+ * enabled: new Set(ALL_PATTERN_IDS),
12
+ * ignore: new IgnoreMatcher([]),
13
+ * maxFileSizeKb: 0,
14
+ * pythonFallback: true,
15
+ * minConfidence: 'low',
16
+ * });
17
+ * console.log(result.findings);
18
+ * ```
19
+ */
20
+ export { scan, ScanError } from './scanner';
21
+ export type { ScanOptions, ScanResult, PythonMode } from './scanner';
22
+ export { applyFixes, isFixable } from './autofix';
23
+ export type { FixResult } from './autofix';
24
+ export { renderJson, renderMarkdown, renderSarif, toPublicFinding } from './reporters';
25
+ export { RULES } from './rules';
26
+ export { IgnoreMatcher } from './ignore';
27
+ export { SPEC_URL, SPEC_DATE, CHANGELOG_URL, MANUAL_REVIEW, getVersion } from './constants';
28
+ export { ALL_PATTERN_IDS } from './types';
29
+ export type { Finding, PatternId, Severity, Confidence, Token } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ALL_PATTERN_IDS = exports.getVersion = exports.MANUAL_REVIEW = exports.CHANGELOG_URL = exports.SPEC_DATE = exports.SPEC_URL = exports.IgnoreMatcher = exports.RULES = exports.toPublicFinding = exports.renderSarif = exports.renderMarkdown = exports.renderJson = exports.isFixable = exports.applyFixes = exports.ScanError = exports.scan = void 0;
4
+ /**
5
+ * Programmatic API for mcp-vet.
6
+ *
7
+ * The CLI (`dist/cli.js`) is the primary entry point, but the scanner and its
8
+ * reporters are usable as a library — e.g. from an editor extension, a custom CI
9
+ * step, or a migration harness:
10
+ *
11
+ * ```ts
12
+ * import { scan, renderJson, applyFixes } from '@booyaka/mcp-vet';
13
+ * const result = scan(['./src'], {
14
+ * enabled: new Set(ALL_PATTERN_IDS),
15
+ * ignore: new IgnoreMatcher([]),
16
+ * maxFileSizeKb: 0,
17
+ * pythonFallback: true,
18
+ * minConfidence: 'low',
19
+ * });
20
+ * console.log(result.findings);
21
+ * ```
22
+ */
23
+ var scanner_1 = require("./scanner");
24
+ Object.defineProperty(exports, "scan", { enumerable: true, get: function () { return scanner_1.scan; } });
25
+ Object.defineProperty(exports, "ScanError", { enumerable: true, get: function () { return scanner_1.ScanError; } });
26
+ var autofix_1 = require("./autofix");
27
+ Object.defineProperty(exports, "applyFixes", { enumerable: true, get: function () { return autofix_1.applyFixes; } });
28
+ Object.defineProperty(exports, "isFixable", { enumerable: true, get: function () { return autofix_1.isFixable; } });
29
+ var reporters_1 = require("./reporters");
30
+ Object.defineProperty(exports, "renderJson", { enumerable: true, get: function () { return reporters_1.renderJson; } });
31
+ Object.defineProperty(exports, "renderMarkdown", { enumerable: true, get: function () { return reporters_1.renderMarkdown; } });
32
+ Object.defineProperty(exports, "renderSarif", { enumerable: true, get: function () { return reporters_1.renderSarif; } });
33
+ Object.defineProperty(exports, "toPublicFinding", { enumerable: true, get: function () { return reporters_1.toPublicFinding; } });
34
+ var rules_1 = require("./rules");
35
+ Object.defineProperty(exports, "RULES", { enumerable: true, get: function () { return rules_1.RULES; } });
36
+ var ignore_1 = require("./ignore");
37
+ Object.defineProperty(exports, "IgnoreMatcher", { enumerable: true, get: function () { return ignore_1.IgnoreMatcher; } });
38
+ var constants_1 = require("./constants");
39
+ Object.defineProperty(exports, "SPEC_URL", { enumerable: true, get: function () { return constants_1.SPEC_URL; } });
40
+ Object.defineProperty(exports, "SPEC_DATE", { enumerable: true, get: function () { return constants_1.SPEC_DATE; } });
41
+ Object.defineProperty(exports, "CHANGELOG_URL", { enumerable: true, get: function () { return constants_1.CHANGELOG_URL; } });
42
+ Object.defineProperty(exports, "MANUAL_REVIEW", { enumerable: true, get: function () { return constants_1.MANUAL_REVIEW; } });
43
+ Object.defineProperty(exports, "getVersion", { enumerable: true, get: function () { return constants_1.getVersion; } });
44
+ var types_1 = require("./types");
45
+ Object.defineProperty(exports, "ALL_PATTERN_IDS", { enumerable: true, get: function () { return types_1.ALL_PATTERN_IDS; } });
@@ -0,0 +1,11 @@
1
+ import { Token } from './types';
2
+ /** Locate a usable Python 3 interpreter, trying platform-appropriate names. */
3
+ export declare function findPython(): string | null;
4
+ export declare function pythonAvailable(): boolean;
5
+ /**
6
+ * Analyze a batch of Python files via the bundled subprocess script. Files are
7
+ * processed in chunks so a single pathological file can only lose its own chunk,
8
+ * and so stdin/stdout stay well under buffer limits on large repos.
9
+ * Returns a map of absolute-file-path -> tokens.
10
+ */
11
+ export declare function analyzePyBatch(absFiles: string[]): Record<string, Token[]>;
@@ -0,0 +1,9 @@
1
+ import { Token } from './types';
2
+ /**
3
+ * Degraded, regex-based tokenizer for Python source, used ONLY when no Python
4
+ * interpreter is available. It cannot verify AST structure, so capability
5
+ * findings fall back to the line-proximity heuristic (medium confidence) and it
6
+ * may over-report inside comments/docstrings. Deterministic string/number rules
7
+ * (session id, initialize, -32002, tasks/*) remain reliable.
8
+ */
9
+ export declare function regexFallbackTokens(text: string): Token[];
@@ -22,9 +22,23 @@ sys.setrecursionlimit(20000)
22
22
  CAP = {"roots", "sampling", "logging"}
23
23
  INIT_STRINGS = {"initialize", "notifications/initialized"}
24
24
  HANDLERISH = re.compile(r"handler|handle|register|route|request|notification|method|^on$", re.I)
25
+ CAPS_RE = re.compile(r"capabilit", re.I)
25
26
  METHODISH = ("method", "type")
26
27
 
27
28
 
29
+ def _func_mentions_caps(func):
30
+ """True when a call target names a capabilities container, e.g.
31
+ ClientCapabilities(...) / ServerCapabilities(...) — the Python SDK's way of
32
+ declaring capabilities, so `roots=`/`sampling=`/`logging=` kwargs inside are
33
+ structural (high confidence), not merely near the word 'capabilities'."""
34
+ name = ""
35
+ if isinstance(func, ast.Attribute):
36
+ name = func.attr
37
+ elif isinstance(func, ast.Name):
38
+ name = func.id
39
+ return bool(CAPS_RE.search(name))
40
+
41
+
28
42
  def _is_int_constant(node):
29
43
  return (
30
44
  isinstance(node, ast.Constant)
@@ -33,9 +47,14 @@ def _is_int_constant(node):
33
47
  )
34
48
 
35
49
 
36
- def _col(node):
37
- c = getattr(node, "col_offset", None)
38
- return (c + 1) if isinstance(c, int) else None
50
+ def _byte_to_char_col(line, byte_off):
51
+ """Convert a CPython ``ast`` UTF-8 *byte* col_offset to a 1-based *character*
52
+ column, matching ts-morph and JS string indexing. col_offset always lands on
53
+ a character boundary (start of a node), so the truncated decode is clean."""
54
+ if line is None:
55
+ return byte_off + 1
56
+ prefix = line.encode("utf-8", "surrogatepass")[:byte_off]
57
+ return len(prefix.decode("utf-8", "ignore")) + 1
39
58
 
40
59
 
41
60
  def _mentions_method(o):
@@ -85,13 +104,22 @@ def _is_registration(node):
85
104
 
86
105
 
87
106
  class Scanner:
88
- def __init__(self):
107
+ def __init__(self, lines):
89
108
  self.tokens = []
109
+ self.lines = lines
110
+
111
+ def _col(self, node):
112
+ c = getattr(node, "col_offset", None)
113
+ if not isinstance(c, int):
114
+ return None
115
+ ln = getattr(node, "lineno", None)
116
+ line = self.lines[ln - 1] if isinstance(ln, int) and 1 <= ln <= len(self.lines) else None
117
+ return _byte_to_char_col(line, c)
90
118
 
91
119
  def emit_for(self, node, in_caps):
92
120
  if isinstance(node, ast.Constant):
93
121
  if isinstance(node.value, str):
94
- tok = {"kind": "string", "value": node.value, "line": node.lineno, "col": _col(node)}
122
+ tok = {"kind": "string", "value": node.value, "line": node.lineno, "col": self._col(node)}
95
123
  if node.value in CAP:
96
124
  tok["inCapabilities"] = in_caps
97
125
  if node.value in INIT_STRINGS:
@@ -99,17 +127,17 @@ class Scanner:
99
127
  self.tokens.append(tok)
100
128
  elif _is_int_constant(node):
101
129
  self.tokens.append(
102
- {"kind": "number", "value": str(node.value), "line": node.lineno, "col": _col(node)}
130
+ {"kind": "number", "value": str(node.value), "line": node.lineno, "col": self._col(node)}
103
131
  )
104
132
  elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub) and _is_int_constant(node.operand):
105
- op = node.operand
133
+ # Anchor negative numbers at the '-' (the UnaryOp), matching ts-morph.
106
134
  self.tokens.append(
107
- {"kind": "number", "value": str(-op.value), "line": op.lineno, "col": _col(node)}
135
+ {"kind": "number", "value": str(-node.operand.value), "line": node.lineno, "col": self._col(node)}
108
136
  )
109
137
  elif isinstance(node, ast.Name):
110
- self.tokens.append({"kind": "name", "value": node.id, "line": node.lineno, "col": _col(node)})
138
+ self.tokens.append({"kind": "name", "value": node.id, "line": node.lineno, "col": self._col(node)})
111
139
  elif isinstance(node, ast.Attribute):
112
- self.tokens.append({"kind": "name", "value": node.attr, "line": node.lineno, "col": _col(node)})
140
+ self.tokens.append({"kind": "name", "value": node.attr, "line": node.lineno, "col": self._col(node)})
113
141
 
114
142
  def visit(self, node, in_caps):
115
143
  self.emit_for(node, in_caps)
@@ -119,7 +147,7 @@ class Scanner:
119
147
  if k is not None:
120
148
  self.visit(k, in_caps)
121
149
  if isinstance(k, ast.Constant) and isinstance(k.value, str):
122
- tok = {"kind": "key", "value": k.value, "line": k.lineno, "col": _col(k)}
150
+ tok = {"kind": "key", "value": k.value, "line": k.lineno, "col": self._col(k)}
123
151
  if k.value in CAP:
124
152
  tok["inCapabilities"] = in_caps
125
153
  self.tokens.append(tok)
@@ -128,17 +156,20 @@ class Scanner:
128
156
  return
129
157
 
130
158
  if isinstance(node, ast.Call):
159
+ # A call to ClientCapabilities(...) / ServerCapabilities(...) is itself
160
+ # a capabilities container — its args/kwargs are structurally in-caps.
161
+ caps_ctx = in_caps or _func_mentions_caps(node.func)
131
162
  self.visit(node.func, in_caps)
132
163
  for a in node.args:
133
- self.visit(a, in_caps)
164
+ self.visit(a, caps_ctx)
134
165
  for kw in node.keywords:
135
166
  if kw.arg is not None:
136
167
  line = getattr(kw, "lineno", None) or getattr(kw.value, "lineno", 0)
137
- tok = {"kind": "key", "value": kw.arg, "line": line, "col": _col(kw)}
168
+ tok = {"kind": "key", "value": kw.arg, "line": line, "col": self._col(kw)}
138
169
  if kw.arg in CAP:
139
- tok["inCapabilities"] = in_caps
170
+ tok["inCapabilities"] = caps_ctx
140
171
  self.tokens.append(tok)
141
- child_caps = in_caps or (kw.arg == "capabilities")
172
+ child_caps = caps_ctx or (kw.arg == "capabilities")
142
173
  self.visit(kw.value, child_caps)
143
174
  return
144
175
 
@@ -154,7 +185,7 @@ def scan_source(src):
154
185
  for n in ast.walk(tree):
155
186
  for c in ast.iter_child_nodes(n):
156
187
  c.parent = n
157
- scanner = Scanner()
188
+ scanner = Scanner(src.split("\n"))
158
189
  try:
159
190
  scanner.visit(tree, False)
160
191
  except RecursionError:
@@ -0,0 +1,34 @@
1
+ import { Finding, Severity } from './types';
2
+ import { ScanResult } from './scanner';
3
+ export interface TerminalOptions {
4
+ color?: boolean;
5
+ }
6
+ /** (a) Terminal report — red for BREAKING, yellow for DEPRECATED, compiler-style. */
7
+ export declare function reportTerminal(result: ScanResult, opts?: TerminalOptions): void;
8
+ /** (b) Markdown table report. */
9
+ export declare function renderMarkdown(result: ScanResult): string;
10
+ export declare function writeMarkdown(result: ScanResult, outDir: string): string;
11
+ /** Public projection of a finding for serialized output (drops internal fields). */
12
+ export declare function toPublicFinding(f: Finding): {
13
+ file: string;
14
+ line: number;
15
+ column: number | null;
16
+ endColumn: number | null;
17
+ patternId: import("./types").PatternId;
18
+ patternLabel: string;
19
+ severity: Severity;
20
+ confidence: import("./types").Confidence;
21
+ explanation: string;
22
+ docUrl: string;
23
+ source: "ts-morph" | "python-ast" | "regex" | null;
24
+ before: string;
25
+ after: string;
26
+ };
27
+ /** (c) Structured JSON array of all findings. */
28
+ export declare function renderJson(result: ScanResult): string;
29
+ export declare function writeJson(result: ScanResult, outDir: string): string;
30
+ /** (d) GitHub Actions native annotations. ::error for BREAKING, ::warning for DEPRECATED. */
31
+ export declare function printGithubAnnotations(findings: Finding[]): void;
32
+ /** (e) SARIF 2.1.0 for GitHub code scanning / other SARIF consumers. */
33
+ export declare function renderSarif(result: ScanResult): string;
34
+ export declare function writeSarif(result: ScanResult, outPath: string): string;
package/dist/reporters.js CHANGED
@@ -86,6 +86,7 @@ function reportTerminal(result, opts = {}) {
86
86
  const suffix = result.suppressedCount > 0 ? ` (${result.suppressedCount} suppressed)` : '';
87
87
  console.log(c.green('✔ mcp-vet: no matching 2026-07-28 breaking or deprecated patterns found') +
88
88
  c.gray(` — ${result.filesScanned} file(s) scanned${suffix}`));
89
+ printManualReview(c);
89
90
  return;
90
91
  }
91
92
  let currentFile = '';
@@ -111,6 +112,11 @@ function reportTerminal(result, opts = {}) {
111
112
  const suppressed = result.suppressedCount > 0 ? c.gray(` (${result.suppressedCount} suppressed)`) : '';
112
113
  console.log((breaking > 0 ? c.red.bold(summary) : c.yellow.bold(summary)) + suppressed);
113
114
  console.log(c.gray(`See ${constants_1.SPEC_URL}`));
115
+ printManualReview(c);
116
+ }
117
+ /** One-line pointer to the changes static analysis can't catch — keeps the tool honest. */
118
+ function printManualReview(c) {
119
+ console.error(c.gray(`note: ${constants_1.MANUAL_REVIEW.length} more 2026-07-28 changes need manual review (SSE push, required headers, auth, JSON Schema 2020-12) — see the README "Needs manual review" section.`));
114
120
  }
115
121
  function mdEscape(s) {
116
122
  return s.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
@@ -0,0 +1,27 @@
1
+ import { Token, Finding, PatternId, Severity } from './types';
2
+ interface RuleMeta {
3
+ id: PatternId;
4
+ label: string;
5
+ severity: Severity;
6
+ explanation: string;
7
+ after: string;
8
+ }
9
+ /**
10
+ * Canonical metadata for each of the 7 patterns. The `after` strings are the
11
+ * corrected 2026-07-28 patterns, authored from the official RC post
12
+ * (blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate) and the
13
+ * tokenmix protocol changelog.
14
+ */
15
+ export declare const RULES: Record<PatternId, RuleMeta>;
16
+ export interface EngineOptions {
17
+ /** the set of pattern IDs to evaluate (already resolved from only/disable) */
18
+ enabled: Set<PatternId>;
19
+ absPath: string;
20
+ source: NonNullable<Finding['source']>;
21
+ }
22
+ /**
23
+ * Apply the enabled detection rules to the tokens of a single file, producing
24
+ * findings with a confidence score.
25
+ */
26
+ export declare function applyRules(relPath: string, lines: string[], tokens: Token[], opts: EngineOptions): Finding[];
27
+ export {};
package/dist/rules.js CHANGED
@@ -50,6 +50,26 @@ exports.RULES = {
50
50
  '// using the NEW argument shapes — review your params against the RC schema.',
51
51
  ].join('\n'),
52
52
  },
53
+ TASKS_LIST_REMOVED: {
54
+ id: 'TASKS_LIST_REMOVED',
55
+ label: 'removed tasks/list method',
56
+ severity: 'BREAKING',
57
+ explanation: 'The tasks/list method is removed entirely on 2026-07-28 (unsafe once protocol-level sessions are gone); there is no drop-in replacement — stop calling/handling it and track task handles yourself.',
58
+ after: [
59
+ '// 2026-07-28: tasks/list is REMOVED — there is no server-side task listing.',
60
+ '// A client tracks the task handles it received from tools/call; there is nothing to enumerate.',
61
+ ].join('\n'),
62
+ },
63
+ TASKS_RESULT_REMOVED: {
64
+ id: 'TASKS_RESULT_REMOVED',
65
+ label: 'removed tasks/result method',
66
+ severity: 'BREAKING',
67
+ explanation: 'The blocking tasks/result method is removed on 2026-07-28 (SEP-2663); poll for completion and read the result with tasks/get instead.',
68
+ after: [
69
+ '// 2026-07-28: tasks/result is REMOVED — the blocking result call is gone.',
70
+ '// Poll tasks/get until the task is terminal and read its result from there.',
71
+ ].join('\n'),
72
+ },
53
73
  ROOTS_CAP: {
54
74
  id: 'ROOTS_CAP',
55
75
  label: 'roots capability',
@@ -78,6 +98,39 @@ const CAP_NAMES = {
78
98
  sampling: 'SAMPLING_CAP',
79
99
  logging: 'LOGGING_CAP',
80
100
  };
101
+ // Method-name strings of the deprecated capabilities (SEP-2577). The methods are
102
+ // deprecated, not just the capability keys — a server that references these by
103
+ // method string (with no literal `capabilities` object nearby) is caught here.
104
+ const DEPRECATED_METHODS = {
105
+ 'roots/list': 'ROOTS_CAP',
106
+ 'notifications/roots/list_changed': 'ROOTS_CAP',
107
+ 'sampling/createMessage': 'SAMPLING_CAP',
108
+ 'logging/setLevel': 'LOGGING_CAP',
109
+ 'notifications/message': 'LOGGING_CAP',
110
+ };
111
+ // SDK request/notification *schema constants* — how real MCP SDK servers register
112
+ // handlers (e.g. `server.setRequestHandler(InitializeRequestSchema, ...)`). Matching
113
+ // the exact string literal alone misses these entirely.
114
+ const SCHEMA_CONSTANTS = {
115
+ InitializeRequestSchema: 'INITIALIZE_HANDLER',
116
+ InitializedNotificationSchema: 'INITIALIZE_HANDLER',
117
+ ListRootsRequestSchema: 'ROOTS_CAP',
118
+ RootsListChangedNotificationSchema: 'ROOTS_CAP',
119
+ CreateMessageRequestSchema: 'SAMPLING_CAP',
120
+ SetLevelRequestSchema: 'LOGGING_CAP',
121
+ LoggingMessageNotificationSchema: 'LOGGING_CAP',
122
+ ListTasksRequestSchema: 'TASKS_LIST_REMOVED',
123
+ GetTaskResultRequestSchema: 'TASKS_RESULT_REMOVED',
124
+ GetTaskRequestSchema: 'TASKS_LEGACY',
125
+ CancelTaskRequestSchema: 'TASKS_LEGACY',
126
+ };
127
+ // SDK capability *constructor* identifiers (esp. the Python SDK:
128
+ // `ClientCapabilities(roots=RootsCapability())`). Unambiguous deprecated-feature use.
129
+ const CAP_CONSTRUCTORS = {
130
+ RootsCapability: 'ROOTS_CAP',
131
+ SamplingCapability: 'SAMPLING_CAP',
132
+ LoggingCapability: 'LOGGING_CAP',
133
+ };
81
134
  function snippet(lines, line) {
82
135
  const idx = line - 1;
83
136
  const out = [];
@@ -150,6 +203,35 @@ function applyRules(relPath, lines, tokens, opts) {
150
203
  (v === 'tasks/get' || v === 'tasks/update' || v === 'tasks/cancel')) {
151
204
  push('TASKS_LEGACY', t, 'high');
152
205
  }
206
+ // Rule 4b — tasks/list is removed entirely (exact string literal)
207
+ if (t.kind === 'string' && v === 'tasks/list') {
208
+ push('TASKS_LIST_REMOVED', t, 'high');
209
+ }
210
+ // Rule 4c — tasks/result is removed (exact string literal)
211
+ if (t.kind === 'string' && v === 'tasks/result') {
212
+ push('TASKS_RESULT_REMOVED', t, 'high');
213
+ }
214
+ // Rule 8 — deprecated-capability method strings (exact, high confidence)
215
+ if (t.kind === 'string' && DEPRECATED_METHODS[v]) {
216
+ push(DEPRECATED_METHODS[v], t, 'high');
217
+ }
218
+ // Rule 9 — SDK schema-constant identifiers used to register handlers
219
+ if (t.kind === 'name' && SCHEMA_CONSTANTS[v]) {
220
+ push(SCHEMA_CONSTANTS[v], t, 'high');
221
+ }
222
+ // Rule 9b — SDK capability constructor identifiers (RootsCapability, ...)
223
+ if (t.kind === 'name' && CAP_CONSTRUCTORS[v]) {
224
+ push(CAP_CONSTRUCTORS[v], t, 'high');
225
+ }
226
+ // Rule 10 — `sessionIdGenerator` option (TS SDK session usage). The correct
227
+ // migration is `sessionIdGenerator: undefined`, so the analyzer marks that
228
+ // benign; only a real generator is flagged, at medium confidence. TS only.
229
+ if (opts.source === 'ts-morph' &&
230
+ t.kind === 'key' &&
231
+ v === 'sessionIdGenerator' &&
232
+ !t.benign) {
233
+ push('MCP_SESSION_ID', t, 'medium');
234
+ }
153
235
  // Rules 5-7 — deprecated capabilities.
154
236
  // High confidence when structurally inside a `capabilities` object (AST);
155
237
  // medium when only within 5 lines of a "capabilities" mention.
@@ -0,0 +1,23 @@
1
+ import { Finding, PatternId, Confidence } from './types';
2
+ import { IgnoreMatcher } from './ignore';
3
+ export interface ScanOptions {
4
+ enabled: Set<PatternId>;
5
+ ignore: IgnoreMatcher;
6
+ /** 0 = no limit */
7
+ maxFileSizeKb: number;
8
+ pythonFallback: boolean;
9
+ minConfidence: Confidence;
10
+ }
11
+ export type PythonMode = 'ast' | 'regex' | 'none' | 'n/a';
12
+ export interface ScanResult {
13
+ findings: Finding[];
14
+ filesScanned: number;
15
+ pythonFilesFound: number;
16
+ pythonMode: PythonMode;
17
+ suppressedCount: number;
18
+ skippedLargeFiles: string[];
19
+ roots: string[];
20
+ }
21
+ export declare class ScanError extends Error {
22
+ }
23
+ export declare function scan(roots: string[], opts: ScanOptions): ScanResult;
@@ -0,0 +1,15 @@
1
+ import { PatternId } from './types';
2
+ export interface Suppressions {
3
+ fileDisabled: boolean;
4
+ /** line (1-indexed) -> set of suppressed pattern IDs (empty set = all) */
5
+ byLine: Map<number, Set<PatternId>>;
6
+ isSuppressed(line: number, id: PatternId): boolean;
7
+ }
8
+ /**
9
+ * Parse inline suppression directives from a file's raw lines. Recognized in any
10
+ * comment style (they are matched textually):
11
+ * mcp-vet-disable-file
12
+ * mcp-vet-disable-line [PATTERN_ID ...]
13
+ * mcp-vet-disable-next-line [PATTERN_ID ...]
14
+ */
15
+ export declare function parseSuppressions(lines: string[]): Suppressions;
@@ -0,0 +1,7 @@
1
+ import { Token } from './types';
2
+ /**
3
+ * Parse a TypeScript/JavaScript file with ts-morph and emit normalized tokens:
4
+ * string literals, numeric literals (with sign), identifiers, and object keys —
5
+ * annotated with structural capability context and registration context.
6
+ */
7
+ export declare function analyzeTs(absPath: string, text: string): Token[];
@@ -168,13 +168,16 @@ function analyzeTs(absPath, text) {
168
168
  if (kind === ts_morph_1.SyntaxKind.NumericLiteral) {
169
169
  const raw = node.getText();
170
170
  let value = raw;
171
+ let anchor = node; // for negatives, anchor at the '-' so col + value.length is exact
171
172
  const parent = node.getParent();
172
173
  if (parent && parent.getKind() === ts_morph_1.SyntaxKind.PrefixUnaryExpression) {
173
174
  const pu = parent.asKind(ts_morph_1.SyntaxKind.PrefixUnaryExpression);
174
- if (pu && pu.getOperatorToken() === ts_morph_1.SyntaxKind.MinusToken)
175
+ if (pu && pu.getOperatorToken() === ts_morph_1.SyntaxKind.MinusToken) {
175
176
  value = '-' + raw;
177
+ anchor = parent;
178
+ }
176
179
  }
177
- const { line, col } = posOf(node);
180
+ const { line, col } = posOf(anchor);
178
181
  tokens.push({ kind: 'number', value, line, col });
179
182
  return;
180
183
  }
@@ -185,16 +188,25 @@ function analyzeTs(absPath, text) {
185
188
  }
186
189
  });
187
190
  // Object literal keys (roots:, "sampling":, logging shorthand, ...)
188
- const emitKey = (nameNode, value) => {
191
+ const emitKey = (nameNode, value, benign = false) => {
189
192
  const { line, col } = posOf(nameNode);
190
193
  const tok = { kind: 'key', value, line, col };
191
194
  if (CAP.has(value))
192
195
  tok.inCapabilities = isInCapabilities(nameNode);
196
+ if (benign)
197
+ tok.benign = true;
193
198
  tokens.push(tok);
194
199
  };
195
200
  for (const pa of sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.PropertyAssignment)) {
196
201
  try {
197
- emitKey(pa.getNameNode(), pa.getName());
202
+ const name = pa.getName();
203
+ // `sessionIdGenerator: undefined` (or null) is the migrated, stateless form.
204
+ let benign = false;
205
+ if (name === 'sessionIdGenerator') {
206
+ const init = pa.getInitializer()?.getText();
207
+ benign = init === 'undefined' || init === 'null';
208
+ }
209
+ emitKey(pa.getNameNode(), name, benign);
198
210
  }
199
211
  catch {
200
212
  /* computed / unusual key */
@@ -0,0 +1,59 @@
1
+ export type Severity = 'BREAKING' | 'DEPRECATED';
2
+ export type Confidence = 'high' | 'medium' | 'low';
3
+ export type PatternId = 'MCP_SESSION_ID' | 'INITIALIZE_HANDLER' | 'ERROR_CODE_32002' | 'TASKS_LEGACY' | 'TASKS_LIST_REMOVED' | 'TASKS_RESULT_REMOVED' | 'ROOTS_CAP' | 'SAMPLING_CAP' | 'LOGGING_CAP';
4
+ export declare const ALL_PATTERN_IDS: PatternId[];
5
+ /**
6
+ * A normalized syntactic token emitted by a language analyzer. Every analyzer
7
+ * (ts-morph, the Python subprocess, and the regex fallback) emits this exact
8
+ * shape so the rule engine can be written once and applied uniformly.
9
+ */
10
+ export interface Token {
11
+ /** string literal, numeric literal, identifier/variable name, or object key */
12
+ kind: 'string' | 'number' | 'name' | 'key';
13
+ /** literal text of the token. For numbers, the signed decimal (e.g. "-32002"). */
14
+ value: string;
15
+ /** 1-indexed line number */
16
+ line: number;
17
+ /** 1-indexed column number, when known */
18
+ col?: number;
19
+ /**
20
+ * True when this token is *structurally* inside a `capabilities`
21
+ * object/argument (AST-verified). Drives high-confidence capability findings.
22
+ */
23
+ inCapabilities?: boolean;
24
+ /**
25
+ * True when a string literal appears in a method-registration / switch-case /
26
+ * method-comparison context (used to raise confidence for `initialize`).
27
+ */
28
+ registration?: boolean;
29
+ /**
30
+ * True when this token is an already-migrated no-op that must NOT be flagged —
31
+ * e.g. `sessionIdGenerator: undefined`, the documented stateless migration.
32
+ */
33
+ benign?: boolean;
34
+ }
35
+ export interface Finding {
36
+ /** path relative to the scan root, forward-slashed */
37
+ file: string;
38
+ line: number;
39
+ /** 1-indexed column, when known */
40
+ column?: number;
41
+ /** 1-indexed end column, when known (for SARIF regions / editor selection) */
42
+ endColumn?: number;
43
+ patternId: PatternId;
44
+ patternLabel: string;
45
+ severity: Severity;
46
+ confidence: Confidence;
47
+ /** one-sentence explanation of what changes */
48
+ explanation: string;
49
+ /** canonical docs anchor for this pattern */
50
+ docUrl: string;
51
+ /** the offending line + one line of context */
52
+ before: string;
53
+ /** the correct 2026-07-28 pattern */
54
+ after: string;
55
+ /** absolute path — internal only, stripped from serialized output */
56
+ absPath?: string;
57
+ /** the analyzer that produced it — internal only */
58
+ source?: 'ts-morph' | 'python-ast' | 'regex';
59
+ }
package/dist/types.js CHANGED
@@ -6,6 +6,8 @@ exports.ALL_PATTERN_IDS = [
6
6
  'INITIALIZE_HANDLER',
7
7
  'ERROR_CODE_32002',
8
8
  'TASKS_LEGACY',
9
+ 'TASKS_LIST_REMOVED',
10
+ 'TASKS_RESULT_REMOVED',
9
11
  'ROOTS_CAP',
10
12
  'SAMPLING_CAP',
11
13
  'LOGGING_CAP',
package/package.json CHANGED
@@ -1,13 +1,24 @@
1
1
  {
2
2
  "name": "@booyaka/mcp-vet",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "sideEffects": false,
6
16
  "bin": {
7
17
  "mcp-vet": "dist/cli.js"
8
18
  },
9
19
  "files": [
10
20
  "dist",
21
+ "schema",
11
22
  "README.md",
12
23
  "CHANGELOG.md",
13
24
  "LICENSE"
@@ -0,0 +1,65 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/Booyaka101/mcp-vet/main/schema/mcpvetrc.schema.json",
4
+ "title": "mcp-vet configuration",
5
+ "description": "Configuration for the mcp-vet CLI (.mcpvetrc.json / mcp-vet.config.json). CLI flags override these values.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string",
11
+ "description": "Optional JSON Schema reference for editor autocomplete/validation."
12
+ },
13
+ "ignore": {
14
+ "type": "array",
15
+ "description": "Gitignore-style globs to skip.",
16
+ "items": { "type": "string" }
17
+ },
18
+ "only": {
19
+ "type": "array",
20
+ "description": "Run only these pattern ids.",
21
+ "items": { "$ref": "#/$defs/patternId" }
22
+ },
23
+ "disable": {
24
+ "type": "array",
25
+ "description": "Skip these pattern ids.",
26
+ "items": { "$ref": "#/$defs/patternId" }
27
+ },
28
+ "failOn": {
29
+ "description": "Which findings cause a non-zero exit.",
30
+ "enum": ["breaking", "any", "none"],
31
+ "default": "breaking"
32
+ },
33
+ "minConfidence": {
34
+ "description": "Report only findings at or above this confidence.",
35
+ "enum": ["high", "medium", "low"],
36
+ "default": "low"
37
+ },
38
+ "maxFileSizeKb": {
39
+ "type": "number",
40
+ "description": "Skip files larger than this many KB (0 = no limit).",
41
+ "minimum": 0,
42
+ "default": 1536
43
+ },
44
+ "pythonFallback": {
45
+ "type": "boolean",
46
+ "description": "Use the regex fallback when no Python interpreter is available.",
47
+ "default": true
48
+ }
49
+ },
50
+ "$defs": {
51
+ "patternId": {
52
+ "type": "string",
53
+ "enum": [
54
+ "MCP_SESSION_ID",
55
+ "INITIALIZE_HANDLER",
56
+ "ERROR_CODE_32002",
57
+ "TASKS_LEGACY",
58
+ "TASKS_LIST_REMOVED",
59
+ "ROOTS_CAP",
60
+ "SAMPLING_CAP",
61
+ "LOGGING_CAP"
62
+ ]
63
+ }
64
+ }
65
+ }