@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 +59 -0
- package/README.md +119 -5
- package/dist/autofix.d.ts +26 -0
- package/dist/autofix.js +130 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +45 -7
- package/dist/config.d.ts +19 -0
- package/dist/constants.d.ts +11 -0
- package/dist/constants.js +12 -1
- package/dist/ignore.d.ts +6 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +45 -0
- package/dist/py-analyzer.d.ts +11 -0
- package/dist/py-fallback.d.ts +9 -0
- package/dist/python/mcp_ast_scan.py +47 -16
- package/dist/reporters.d.ts +34 -0
- package/dist/reporters.js +6 -0
- package/dist/rules.d.ts +27 -0
- package/dist/rules.js +82 -0
- package/dist/scanner.d.ts +23 -0
- package/dist/suppress.d.ts +15 -0
- package/dist/ts-analyzer.d.ts +7 -0
- package/dist/ts-analyzer.js +16 -4
- package/dist/types.d.ts +59 -0
- package/dist/types.js +2 -0
- package/package.json +12 -1
- package/schema/mcpvetrc.schema.json +65 -0
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
|
-
|
|
43
|
+
6 finding(s): 5 BREAKING, 1 DEPRECATED
|
|
36
44
|
```
|
|
37
45
|
|
|
38
|
-
And it stays quiet where it should
|
|
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
|
|
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 (
|
|
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;
|
package/dist/autofix.js
ADDED
|
@@ -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
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
|
-
|
|
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 (!
|
|
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 (!
|
|
209
|
-
|
|
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 (!
|
|
220
|
-
|
|
221
|
-
|
|
257
|
+
if (!quiet) {
|
|
258
|
+
notify(`Wrote ${md}`);
|
|
259
|
+
notify(`Wrote ${json}`);
|
|
222
260
|
}
|
|
223
261
|
}
|
|
224
262
|
catch (err) {
|
package/dist/config.d.ts
ADDED
|
@@ -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 = [
|
package/dist/ignore.d.ts
ADDED
package/dist/index.d.ts
ADDED
|
@@ -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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
133
|
+
# Anchor negative numbers at the '-' (the UnaryOp), matching ts-morph.
|
|
106
134
|
self.tokens.append(
|
|
107
|
-
{"kind": "number", "value": str(-
|
|
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,
|
|
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"] =
|
|
170
|
+
tok["inCapabilities"] = caps_ctx
|
|
140
171
|
self.tokens.append(tok)
|
|
141
|
-
child_caps =
|
|
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, ' ');
|
package/dist/rules.d.ts
ADDED
|
@@ -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[];
|
package/dist/ts-analyzer.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 */
|
package/dist/types.d.ts
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@booyaka/mcp-vet",
|
|
3
|
-
"version": "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
|
+
}
|