@booyaka/mcp-vet 0.2.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 +54 -0
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/dist/cli.js +238 -0
- package/dist/config.js +79 -0
- package/dist/constants.js +60 -0
- package/dist/ignore.js +66 -0
- package/dist/py-analyzer.js +114 -0
- package/dist/py-fallback.js +44 -0
- package/dist/python/mcp_ast_scan.py +183 -0
- package/dist/reporters.js +253 -0
- package/dist/rules.js +166 -0
- package/dist/scanner.js +235 -0
- package/dist/suppress.js +66 -0
- package/dist/ts-analyzer.js +216 -0
- package/dist/types.js +12 -0
- package/package.json +60 -0
|
@@ -0,0 +1,253 @@
|
|
|
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
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.reportTerminal = reportTerminal;
|
|
40
|
+
exports.renderMarkdown = renderMarkdown;
|
|
41
|
+
exports.writeMarkdown = writeMarkdown;
|
|
42
|
+
exports.toPublicFinding = toPublicFinding;
|
|
43
|
+
exports.renderJson = renderJson;
|
|
44
|
+
exports.writeJson = writeJson;
|
|
45
|
+
exports.printGithubAnnotations = printGithubAnnotations;
|
|
46
|
+
exports.renderSarif = renderSarif;
|
|
47
|
+
exports.writeSarif = writeSarif;
|
|
48
|
+
const fs = __importStar(require("node:fs"));
|
|
49
|
+
const path = __importStar(require("node:path"));
|
|
50
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
51
|
+
const types_1 = require("./types");
|
|
52
|
+
const rules_1 = require("./rules");
|
|
53
|
+
const constants_1 = require("./constants");
|
|
54
|
+
function makeChalk(color) {
|
|
55
|
+
// color === true -> force on; false -> force off; undefined -> auto-detect
|
|
56
|
+
if (color === true)
|
|
57
|
+
return new chalk_1.default.Instance({ level: 1 });
|
|
58
|
+
if (color === false)
|
|
59
|
+
return new chalk_1.default.Instance({ level: 0 });
|
|
60
|
+
return new chalk_1.default.Instance({});
|
|
61
|
+
}
|
|
62
|
+
function indent(text, pad) {
|
|
63
|
+
return text
|
|
64
|
+
.split('\n')
|
|
65
|
+
.map((l) => pad + l)
|
|
66
|
+
.join('\n');
|
|
67
|
+
}
|
|
68
|
+
function countBySeverity(findings) {
|
|
69
|
+
const breaking = findings.filter((f) => f.severity === 'BREAKING').length;
|
|
70
|
+
return { breaking, deprecated: findings.length - breaking };
|
|
71
|
+
}
|
|
72
|
+
/** (a) Terminal report — red for BREAKING, yellow for DEPRECATED, compiler-style. */
|
|
73
|
+
function reportTerminal(result, opts = {}) {
|
|
74
|
+
const c = makeChalk(opts.color);
|
|
75
|
+
const { findings } = result;
|
|
76
|
+
if (result.pythonMode === 'none') {
|
|
77
|
+
console.error(c.yellow(`warning: ${result.pythonFilesFound} Python file(s) found but no Python interpreter was available and --no-py-fallback was set — .py files were not scanned.`));
|
|
78
|
+
}
|
|
79
|
+
else if (result.pythonMode === 'regex') {
|
|
80
|
+
console.error(c.yellow(`warning: no Python interpreter found — scanned ${result.pythonFilesFound} .py file(s) with the regex fallback (reduced precision).`));
|
|
81
|
+
}
|
|
82
|
+
for (const rel of result.skippedLargeFiles) {
|
|
83
|
+
console.error(c.yellow(`warning: skipped large file ${rel} (exceeds --max-file-size).`));
|
|
84
|
+
}
|
|
85
|
+
if (findings.length === 0) {
|
|
86
|
+
const suffix = result.suppressedCount > 0 ? ` (${result.suppressedCount} suppressed)` : '';
|
|
87
|
+
console.log(c.green('✔ mcp-vet: no matching 2026-07-28 breaking or deprecated patterns found') +
|
|
88
|
+
c.gray(` — ${result.filesScanned} file(s) scanned${suffix}`));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let currentFile = '';
|
|
92
|
+
for (const f of findings) {
|
|
93
|
+
if (f.file !== currentFile) {
|
|
94
|
+
currentFile = f.file;
|
|
95
|
+
console.log('');
|
|
96
|
+
console.log(c.underline(f.file));
|
|
97
|
+
}
|
|
98
|
+
const sev = f.severity === 'BREAKING' ? c.red.bold('BREAKING') : c.yellow.bold('DEPRECATED');
|
|
99
|
+
const loc = c.cyan(`${f.file}:${f.line}${f.column ? ':' + f.column : ''}`);
|
|
100
|
+
const conf = c.gray(`[${f.confidence}]`);
|
|
101
|
+
console.log(`${loc} ${sev} ${c.bold(f.patternId)} ${conf}`);
|
|
102
|
+
console.log(indent(f.explanation, ' '));
|
|
103
|
+
console.log(c.gray(' — before:'));
|
|
104
|
+
console.log(indent(f.before, ' '));
|
|
105
|
+
console.log(c.gray(' + after:'));
|
|
106
|
+
console.log(c.green(indent(f.after, ' ')));
|
|
107
|
+
}
|
|
108
|
+
console.log('');
|
|
109
|
+
const { breaking, deprecated } = countBySeverity(findings);
|
|
110
|
+
const summary = `${findings.length} finding(s): ${breaking} BREAKING, ${deprecated} DEPRECATED`;
|
|
111
|
+
const suppressed = result.suppressedCount > 0 ? c.gray(` (${result.suppressedCount} suppressed)`) : '';
|
|
112
|
+
console.log((breaking > 0 ? c.red.bold(summary) : c.yellow.bold(summary)) + suppressed);
|
|
113
|
+
console.log(c.gray(`See ${constants_1.SPEC_URL}`));
|
|
114
|
+
}
|
|
115
|
+
function mdEscape(s) {
|
|
116
|
+
return s.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
117
|
+
}
|
|
118
|
+
/** (b) Markdown table report. */
|
|
119
|
+
function renderMarkdown(result) {
|
|
120
|
+
const { findings } = result;
|
|
121
|
+
const { breaking, deprecated } = countBySeverity(findings);
|
|
122
|
+
const lines = [];
|
|
123
|
+
lines.push('# mcp-vet report');
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push(`Scan for patterns that break under the [MCP 2026-07-28 spec release candidate](${constants_1.SPEC_URL}) (ships final **${constants_1.SPEC_DATE}**).`);
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push(`**${findings.length} finding(s):** ${breaking} BREAKING, ${deprecated} DEPRECATED` +
|
|
128
|
+
(result.suppressedCount ? ` · ${result.suppressedCount} suppressed` : '') +
|
|
129
|
+
`.`);
|
|
130
|
+
lines.push('');
|
|
131
|
+
lines.push('| File | Line | Pattern | Severity | Confidence | Explanation |');
|
|
132
|
+
lines.push('| --- | ---: | --- | --- | --- | --- |');
|
|
133
|
+
for (const f of findings) {
|
|
134
|
+
lines.push(`| \`${mdEscape(f.file)}\` | ${f.line} | ${f.patternId} | ${f.severity} | ${f.confidence} | ${mdEscape(f.explanation)} |`);
|
|
135
|
+
}
|
|
136
|
+
lines.push('');
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
function writeMarkdown(result, outDir) {
|
|
140
|
+
const outPath = path.join(outDir, 'mcp-vet-report.md');
|
|
141
|
+
fs.writeFileSync(outPath, renderMarkdown(result), 'utf8');
|
|
142
|
+
return outPath;
|
|
143
|
+
}
|
|
144
|
+
/** Public projection of a finding for serialized output (drops internal fields). */
|
|
145
|
+
function toPublicFinding(f) {
|
|
146
|
+
return {
|
|
147
|
+
file: f.file,
|
|
148
|
+
line: f.line,
|
|
149
|
+
column: f.column ?? null,
|
|
150
|
+
endColumn: f.endColumn ?? null,
|
|
151
|
+
patternId: f.patternId,
|
|
152
|
+
patternLabel: f.patternLabel,
|
|
153
|
+
severity: f.severity,
|
|
154
|
+
confidence: f.confidence,
|
|
155
|
+
explanation: f.explanation,
|
|
156
|
+
docUrl: f.docUrl,
|
|
157
|
+
source: f.source ?? null,
|
|
158
|
+
before: f.before,
|
|
159
|
+
after: f.after,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** (c) Structured JSON array of all findings. */
|
|
163
|
+
function renderJson(result) {
|
|
164
|
+
return JSON.stringify(result.findings.map(toPublicFinding), null, 2);
|
|
165
|
+
}
|
|
166
|
+
function writeJson(result, outDir) {
|
|
167
|
+
const outPath = path.join(outDir, 'mcp-vet-results.json');
|
|
168
|
+
fs.writeFileSync(outPath, renderJson(result), 'utf8');
|
|
169
|
+
return outPath;
|
|
170
|
+
}
|
|
171
|
+
/** (d) GitHub Actions native annotations. ::error for BREAKING, ::warning for DEPRECATED. */
|
|
172
|
+
function printGithubAnnotations(findings) {
|
|
173
|
+
for (const f of findings) {
|
|
174
|
+
const level = f.severity === 'BREAKING' ? 'error' : 'warning';
|
|
175
|
+
const msg = `${f.patternId} - ${f.explanation}`.replace(/\r?\n/g, ' ');
|
|
176
|
+
const col = f.column ? `,col=${f.column}` : '';
|
|
177
|
+
console.log(`::${level} file=${f.file},line=${f.line}${col}::${msg}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function sarifLevel(sev) {
|
|
181
|
+
return sev === 'BREAKING' ? 'error' : 'warning';
|
|
182
|
+
}
|
|
183
|
+
/** (e) SARIF 2.1.0 for GitHub code scanning / other SARIF consumers. */
|
|
184
|
+
function renderSarif(result) {
|
|
185
|
+
const cwd = process.cwd();
|
|
186
|
+
const rules = types_1.ALL_PATTERN_IDS.map((id) => {
|
|
187
|
+
const r = rules_1.RULES[id];
|
|
188
|
+
return {
|
|
189
|
+
id,
|
|
190
|
+
name: r.label.replace(/\s+/g, ''),
|
|
191
|
+
shortDescription: { text: r.label },
|
|
192
|
+
fullDescription: { text: r.explanation },
|
|
193
|
+
helpUri: constants_1.SPEC_URL,
|
|
194
|
+
defaultConfiguration: { level: sarifLevel(r.severity) },
|
|
195
|
+
properties: { severity: r.severity },
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
const results = result.findings.map((f) => {
|
|
199
|
+
// Prefer a cwd-relative posix uri; if the file lives outside cwd (relative
|
|
200
|
+
// path escapes with "..") fall back to the scan-root-relative path, which
|
|
201
|
+
// GitHub code scanning accepts.
|
|
202
|
+
let uri = f.file;
|
|
203
|
+
if (f.absPath) {
|
|
204
|
+
const relToCwd = toPosix(path.relative(cwd, f.absPath));
|
|
205
|
+
if (relToCwd && !relToCwd.startsWith('..'))
|
|
206
|
+
uri = relToCwd;
|
|
207
|
+
}
|
|
208
|
+
const region = { startLine: f.line };
|
|
209
|
+
if (f.column)
|
|
210
|
+
region.startColumn = f.column;
|
|
211
|
+
if (f.endColumn)
|
|
212
|
+
region.endColumn = f.endColumn;
|
|
213
|
+
return {
|
|
214
|
+
ruleId: f.patternId,
|
|
215
|
+
level: sarifLevel(f.severity),
|
|
216
|
+
message: { text: `${f.patternId}: ${f.explanation}` },
|
|
217
|
+
locations: [
|
|
218
|
+
{
|
|
219
|
+
physicalLocation: {
|
|
220
|
+
artifactLocation: { uri },
|
|
221
|
+
region,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
properties: { confidence: f.confidence, severity: f.severity },
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
const sarif = {
|
|
229
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
230
|
+
version: '2.1.0',
|
|
231
|
+
runs: [
|
|
232
|
+
{
|
|
233
|
+
tool: {
|
|
234
|
+
driver: {
|
|
235
|
+
name: 'mcp-vet',
|
|
236
|
+
informationUri: constants_1.SPEC_URL,
|
|
237
|
+
version: (0, constants_1.getVersion)(),
|
|
238
|
+
rules,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
results,
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
};
|
|
245
|
+
return JSON.stringify(sarif, null, 2);
|
|
246
|
+
}
|
|
247
|
+
function writeSarif(result, outPath) {
|
|
248
|
+
fs.writeFileSync(outPath, renderSarif(result), 'utf8');
|
|
249
|
+
return outPath;
|
|
250
|
+
}
|
|
251
|
+
function toPosix(p) {
|
|
252
|
+
return p.split(path.sep).join('/');
|
|
253
|
+
}
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RULES = void 0;
|
|
4
|
+
exports.applyRules = applyRules;
|
|
5
|
+
const constants_1 = require("./constants");
|
|
6
|
+
/**
|
|
7
|
+
* Canonical metadata for each of the 7 patterns. The `after` strings are the
|
|
8
|
+
* corrected 2026-07-28 patterns, authored from the official RC post
|
|
9
|
+
* (blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate) and the
|
|
10
|
+
* tokenmix protocol changelog.
|
|
11
|
+
*/
|
|
12
|
+
exports.RULES = {
|
|
13
|
+
MCP_SESSION_ID: {
|
|
14
|
+
id: 'MCP_SESSION_ID',
|
|
15
|
+
label: 'Mcp-Session-Id',
|
|
16
|
+
severity: 'BREAKING',
|
|
17
|
+
explanation: 'The Mcp-Session-Id header and protocol-level sessions are removed on 2026-07-28; client info and capabilities now travel in per-request _meta.',
|
|
18
|
+
after: [
|
|
19
|
+
"// 2026-07-28: sessions removed — stop reading/writing the 'Mcp-Session-Id' header.",
|
|
20
|
+
'// Client info & capabilities now arrive in each request’s params._meta.',
|
|
21
|
+
'function handle(req) { const meta = req.params?._meta ?? {}; /* route on meta, not a session id */ }',
|
|
22
|
+
].join('\n'),
|
|
23
|
+
},
|
|
24
|
+
INITIALIZE_HANDLER: {
|
|
25
|
+
id: 'INITIALIZE_HANDLER',
|
|
26
|
+
label: 'initialize handshake',
|
|
27
|
+
severity: 'BREAKING',
|
|
28
|
+
explanation: 'The initialize/notifications-initialized handshake is removed on 2026-07-28; protocolVersion, clientInfo and capabilities now travel in _meta on every request.',
|
|
29
|
+
after: [
|
|
30
|
+
'// 2026-07-28: no initialize / notifications/initialized handshake.',
|
|
31
|
+
'// Read handshake data from _meta on every request instead of registering an initialize handler.',
|
|
32
|
+
'function handle(req) { const { protocolVersion, clientInfo, capabilities } = req.params?._meta ?? {}; }',
|
|
33
|
+
].join('\n'),
|
|
34
|
+
},
|
|
35
|
+
ERROR_CODE_32002: {
|
|
36
|
+
id: 'ERROR_CODE_32002',
|
|
37
|
+
label: 'error code -32002',
|
|
38
|
+
severity: 'BREAKING',
|
|
39
|
+
explanation: 'The missing-resource error code changes from the MCP-custom -32002 to the JSON-RPC standard -32602 (Invalid Params).',
|
|
40
|
+
after: "return { error: { code: -32602, message: 'Invalid params' } }; // was -32002",
|
|
41
|
+
},
|
|
42
|
+
TASKS_LEGACY: {
|
|
43
|
+
id: 'TASKS_LEGACY',
|
|
44
|
+
label: 'legacy Tasks method',
|
|
45
|
+
severity: 'BREAKING',
|
|
46
|
+
explanation: 'The experimental Tasks API is redesigned to a handle-based lifecycle on 2026-07-28; tasks/get, tasks/update and tasks/cancel argument shapes change and need manual review.',
|
|
47
|
+
after: [
|
|
48
|
+
'// 2026-07-28: Tasks is now a handle-based extension.',
|
|
49
|
+
'// tools/call returns a task handle; drive it with tasks/get | tasks/update | tasks/cancel',
|
|
50
|
+
'// using the NEW argument shapes — review your params against the RC schema.',
|
|
51
|
+
].join('\n'),
|
|
52
|
+
},
|
|
53
|
+
ROOTS_CAP: {
|
|
54
|
+
id: 'ROOTS_CAP',
|
|
55
|
+
label: 'roots capability',
|
|
56
|
+
severity: 'DEPRECATED',
|
|
57
|
+
explanation: 'The roots capability is deprecated with a 12-month grace period; it still works but should not be used in new code.',
|
|
58
|
+
after: "// 'roots' capability is deprecated (≥12-month grace). Avoid new use; plan removal.",
|
|
59
|
+
},
|
|
60
|
+
SAMPLING_CAP: {
|
|
61
|
+
id: 'SAMPLING_CAP',
|
|
62
|
+
label: 'sampling capability',
|
|
63
|
+
severity: 'DEPRECATED',
|
|
64
|
+
explanation: 'The sampling capability is deprecated with a 12-month grace period; prefer direct provider APIs.',
|
|
65
|
+
after: "// 'sampling' capability is deprecated (≥12-month grace). Prefer direct provider APIs.",
|
|
66
|
+
},
|
|
67
|
+
LOGGING_CAP: {
|
|
68
|
+
id: 'LOGGING_CAP',
|
|
69
|
+
label: 'logging capability',
|
|
70
|
+
severity: 'DEPRECATED',
|
|
71
|
+
explanation: 'The logging capability is deprecated with a 12-month grace period; use stderr or OpenTelemetry.',
|
|
72
|
+
after: "// 'logging' capability is deprecated (≥12-month grace). Use stderr or OpenTelemetry.",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const CAP_RE = /capabilities/i;
|
|
76
|
+
const CAP_NAMES = {
|
|
77
|
+
roots: 'ROOTS_CAP',
|
|
78
|
+
sampling: 'SAMPLING_CAP',
|
|
79
|
+
logging: 'LOGGING_CAP',
|
|
80
|
+
};
|
|
81
|
+
function snippet(lines, line) {
|
|
82
|
+
const idx = line - 1;
|
|
83
|
+
const out = [];
|
|
84
|
+
if (lines[idx] !== undefined)
|
|
85
|
+
out.push(`${line}: ${lines[idx].trim()}`);
|
|
86
|
+
if (lines[idx + 1] !== undefined)
|
|
87
|
+
out.push(`${line + 1}: ${lines[idx + 1].trim()}`);
|
|
88
|
+
return out.join('\n');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Apply the enabled detection rules to the tokens of a single file, producing
|
|
92
|
+
* findings with a confidence score.
|
|
93
|
+
*/
|
|
94
|
+
function applyRules(relPath, lines, tokens, opts) {
|
|
95
|
+
const findings = [];
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const { enabled } = opts;
|
|
98
|
+
// Lines that mention "capabilities" — drive the medium-confidence heuristic
|
|
99
|
+
// for the DEPRECATED capability rules (5-7).
|
|
100
|
+
const capLines = [];
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
if (CAP_RE.test(lines[i]))
|
|
103
|
+
capLines.push(i + 1);
|
|
104
|
+
}
|
|
105
|
+
const nearCapabilities = (line) => capLines.some((cl) => Math.abs(cl - line) <= 5);
|
|
106
|
+
const push = (id, t, confidence) => {
|
|
107
|
+
if (!enabled.has(id))
|
|
108
|
+
return;
|
|
109
|
+
const key = `${t.line}|${t.col ?? 0}|${id}`;
|
|
110
|
+
if (seen.has(key))
|
|
111
|
+
return;
|
|
112
|
+
seen.add(key);
|
|
113
|
+
const m = exports.RULES[id];
|
|
114
|
+
const column = t.col;
|
|
115
|
+
findings.push({
|
|
116
|
+
file: relPath,
|
|
117
|
+
line: t.line,
|
|
118
|
+
column,
|
|
119
|
+
endColumn: column !== undefined ? column + t.value.length : undefined,
|
|
120
|
+
patternId: id,
|
|
121
|
+
patternLabel: m.label,
|
|
122
|
+
severity: m.severity,
|
|
123
|
+
confidence,
|
|
124
|
+
explanation: m.explanation,
|
|
125
|
+
docUrl: constants_1.SPEC_URL,
|
|
126
|
+
before: snippet(lines, t.line),
|
|
127
|
+
after: m.after,
|
|
128
|
+
absPath: opts.absPath,
|
|
129
|
+
source: opts.source,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
for (const t of tokens) {
|
|
133
|
+
const v = t.value;
|
|
134
|
+
const lower = v.toLowerCase();
|
|
135
|
+
// Rule 1 — Mcp-Session-Id (string literal, header key, or variable name)
|
|
136
|
+
if ((t.kind === 'string' || t.kind === 'name' || t.kind === 'key') &&
|
|
137
|
+
(lower.includes('mcp-session-id') || lower.includes('mcpsessionid'))) {
|
|
138
|
+
push('MCP_SESSION_ID', t, 'high');
|
|
139
|
+
}
|
|
140
|
+
// Rule 2 — initialize handshake (exact method-name string literal)
|
|
141
|
+
if (t.kind === 'string' && (v === 'initialize' || v === 'notifications/initialized')) {
|
|
142
|
+
push('INITIALIZE_HANDLER', t, t.registration ? 'high' : 'low');
|
|
143
|
+
}
|
|
144
|
+
// Rule 3 — legacy error code -32002 (numeric literal)
|
|
145
|
+
if (t.kind === 'number' && v === '-32002') {
|
|
146
|
+
push('ERROR_CODE_32002', t, 'high');
|
|
147
|
+
}
|
|
148
|
+
// Rule 4 — legacy Tasks methods (exact string literals)
|
|
149
|
+
if (t.kind === 'string' &&
|
|
150
|
+
(v === 'tasks/get' || v === 'tasks/update' || v === 'tasks/cancel')) {
|
|
151
|
+
push('TASKS_LEGACY', t, 'high');
|
|
152
|
+
}
|
|
153
|
+
// Rules 5-7 — deprecated capabilities.
|
|
154
|
+
// High confidence when structurally inside a `capabilities` object (AST);
|
|
155
|
+
// medium when only within 5 lines of a "capabilities" mention.
|
|
156
|
+
if ((t.kind === 'key' || t.kind === 'string') && CAP_NAMES[v]) {
|
|
157
|
+
if (t.inCapabilities)
|
|
158
|
+
push(CAP_NAMES[v], t, 'high');
|
|
159
|
+
else if (nearCapabilities(t.line))
|
|
160
|
+
push(CAP_NAMES[v], t, 'medium');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return findings.sort((a, b) => a.line - b.line ||
|
|
164
|
+
(a.column ?? 0) - (b.column ?? 0) ||
|
|
165
|
+
a.patternId.localeCompare(b.patternId));
|
|
166
|
+
}
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
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.ScanError = void 0;
|
|
37
|
+
exports.scan = scan;
|
|
38
|
+
const fs = __importStar(require("node:fs"));
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
40
|
+
const rules_1 = require("./rules");
|
|
41
|
+
const ts_analyzer_1 = require("./ts-analyzer");
|
|
42
|
+
const py_analyzer_1 = require("./py-analyzer");
|
|
43
|
+
const py_fallback_1 = require("./py-fallback");
|
|
44
|
+
const suppress_1 = require("./suppress");
|
|
45
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', 'dist', 'build']);
|
|
46
|
+
const TS_EXT = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
|
|
47
|
+
const PY_EXT = new Set(['.py']);
|
|
48
|
+
const CONF_RANK = { low: 1, medium: 2, high: 3 };
|
|
49
|
+
class ScanError extends Error {
|
|
50
|
+
}
|
|
51
|
+
exports.ScanError = ScanError;
|
|
52
|
+
/** Strip a leading UTF-8 BOM so line 1 and AST parsing behave correctly. */
|
|
53
|
+
function stripBom(s) {
|
|
54
|
+
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
|
55
|
+
}
|
|
56
|
+
function toPosix(p) {
|
|
57
|
+
return p.split(path.sep).join('/');
|
|
58
|
+
}
|
|
59
|
+
function classify(name) {
|
|
60
|
+
const ext = path.extname(name).toLowerCase();
|
|
61
|
+
if (TS_EXT.has(ext))
|
|
62
|
+
return 'ts';
|
|
63
|
+
if (PY_EXT.has(ext))
|
|
64
|
+
return 'py';
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function walk(root, ignore) {
|
|
68
|
+
const files = [];
|
|
69
|
+
const stack = [root];
|
|
70
|
+
while (stack.length) {
|
|
71
|
+
const dir = stack.pop();
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
const full = path.join(dir, e.name);
|
|
81
|
+
const rel = toPosix(path.relative(root, full));
|
|
82
|
+
if (e.isDirectory()) {
|
|
83
|
+
if (SKIP_DIRS.has(e.name))
|
|
84
|
+
continue;
|
|
85
|
+
if (ignore.matches(rel))
|
|
86
|
+
continue;
|
|
87
|
+
stack.push(full);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!e.isFile())
|
|
91
|
+
continue;
|
|
92
|
+
const lang = classify(e.name);
|
|
93
|
+
if (!lang)
|
|
94
|
+
continue;
|
|
95
|
+
if (ignore.matches(rel))
|
|
96
|
+
continue;
|
|
97
|
+
files.push({ abs: full, rel, lang });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return files;
|
|
101
|
+
}
|
|
102
|
+
function collectFiles(roots, ignore) {
|
|
103
|
+
const all = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const root of roots) {
|
|
106
|
+
const abs = path.resolve(root);
|
|
107
|
+
let stat;
|
|
108
|
+
try {
|
|
109
|
+
stat = fs.statSync(abs);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new ScanError(`path does not exist: ${root}`);
|
|
113
|
+
}
|
|
114
|
+
let found;
|
|
115
|
+
if (stat.isFile()) {
|
|
116
|
+
const lang = classify(abs);
|
|
117
|
+
found = lang ? [{ abs, rel: path.basename(abs), lang }] : [];
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
found = walk(abs, ignore);
|
|
121
|
+
}
|
|
122
|
+
for (const f of found) {
|
|
123
|
+
const key = path.normalize(f.abs).toLowerCase();
|
|
124
|
+
if (seen.has(key))
|
|
125
|
+
continue;
|
|
126
|
+
seen.add(key);
|
|
127
|
+
all.push(f);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
all.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
131
|
+
return all;
|
|
132
|
+
}
|
|
133
|
+
function scan(roots, opts) {
|
|
134
|
+
const files = collectFiles(roots, opts.ignore);
|
|
135
|
+
const maxBytes = opts.maxFileSizeKb > 0 ? opts.maxFileSizeKb * 1024 : Infinity;
|
|
136
|
+
const skippedLargeFiles = [];
|
|
137
|
+
const readText = (f) => {
|
|
138
|
+
try {
|
|
139
|
+
if (Number.isFinite(maxBytes)) {
|
|
140
|
+
const size = fs.statSync(f.abs).size;
|
|
141
|
+
if (size > maxBytes) {
|
|
142
|
+
skippedLargeFiles.push(f.rel);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return stripBom(fs.readFileSync(f.abs, 'utf8'));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const rawFindings = [];
|
|
153
|
+
let suppressedCount = 0;
|
|
154
|
+
const emitForFile = (f, lines, tokens, source) => {
|
|
155
|
+
const found = (0, rules_1.applyRules)(f.rel, lines, tokens, {
|
|
156
|
+
enabled: opts.enabled,
|
|
157
|
+
absPath: f.abs,
|
|
158
|
+
source: source,
|
|
159
|
+
});
|
|
160
|
+
if (found.length === 0)
|
|
161
|
+
return;
|
|
162
|
+
const supp = (0, suppress_1.parseSuppressions)(lines);
|
|
163
|
+
for (const finding of found) {
|
|
164
|
+
if (supp.isSuppressed(finding.line, finding.patternId)) {
|
|
165
|
+
suppressedCount++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
rawFindings.push(finding);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
// TypeScript / JavaScript
|
|
172
|
+
for (const f of files) {
|
|
173
|
+
if (f.lang !== 'ts')
|
|
174
|
+
continue;
|
|
175
|
+
const text = readText(f);
|
|
176
|
+
if (text === null)
|
|
177
|
+
continue;
|
|
178
|
+
const lines = text.split(/\r?\n/);
|
|
179
|
+
let tokens = [];
|
|
180
|
+
try {
|
|
181
|
+
tokens = (0, ts_analyzer_1.analyzeTs)(f.abs, text);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
tokens = [];
|
|
185
|
+
}
|
|
186
|
+
emitForFile(f, lines, tokens, 'ts-morph');
|
|
187
|
+
}
|
|
188
|
+
// Python
|
|
189
|
+
const pyFiles = files.filter((f) => f.lang === 'py');
|
|
190
|
+
let pythonMode = 'n/a';
|
|
191
|
+
if (pyFiles.length) {
|
|
192
|
+
const hasPython = (0, py_analyzer_1.pythonAvailable)();
|
|
193
|
+
if (hasPython) {
|
|
194
|
+
pythonMode = 'ast';
|
|
195
|
+
const batch = (0, py_analyzer_1.analyzePyBatch)(pyFiles.map((f) => f.abs));
|
|
196
|
+
for (const f of pyFiles) {
|
|
197
|
+
const text = readText(f);
|
|
198
|
+
if (text === null)
|
|
199
|
+
continue;
|
|
200
|
+
const lines = text.split(/\r?\n/);
|
|
201
|
+
emitForFile(f, lines, batch[f.abs] || [], 'python-ast');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else if (opts.pythonFallback) {
|
|
205
|
+
pythonMode = 'regex';
|
|
206
|
+
for (const f of pyFiles) {
|
|
207
|
+
const text = readText(f);
|
|
208
|
+
if (text === null)
|
|
209
|
+
continue;
|
|
210
|
+
const lines = text.split(/\r?\n/);
|
|
211
|
+
emitForFile(f, lines, (0, py_fallback_1.regexFallbackTokens)(text), 'regex');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
pythonMode = 'none';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Confidence filter
|
|
219
|
+
const minRank = CONF_RANK[opts.minConfidence];
|
|
220
|
+
const findings = rawFindings
|
|
221
|
+
.filter((fnd) => CONF_RANK[fnd.confidence] >= minRank)
|
|
222
|
+
.sort((a, b) => a.file.localeCompare(b.file) ||
|
|
223
|
+
a.line - b.line ||
|
|
224
|
+
(a.column ?? 0) - (b.column ?? 0) ||
|
|
225
|
+
a.patternId.localeCompare(b.patternId));
|
|
226
|
+
return {
|
|
227
|
+
findings,
|
|
228
|
+
filesScanned: files.length,
|
|
229
|
+
pythonFilesFound: pyFiles.length,
|
|
230
|
+
pythonMode,
|
|
231
|
+
suppressedCount,
|
|
232
|
+
skippedLargeFiles,
|
|
233
|
+
roots,
|
|
234
|
+
};
|
|
235
|
+
}
|