@caixm/api-check-mcp 1.0.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.
Files changed (3) hide show
  1. package/README.md +146 -0
  2. package/package.json +22 -0
  3. package/server.js +785 -0
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # api-check-mcp
2
+
3
+ 一个用于扫描前端项目中接口实际使用情况的 MCP Server。
4
+
5
+ 它会读取项目根目录下的 `api-exclude.md`,分析接口是否仍被业务代码使用,并输出一份扫描结果文件。
6
+
7
+ ## 功能
8
+
9
+ - 扫描指定项目中的接口使用情况
10
+ - 支持扫描当前代码
11
+ - 支持根据 commit 列表逐版本扫描
12
+ - 输出 Markdown 格式结果,方便查看和整理
13
+
14
+ ## 安装
15
+
16
+ ```bash
17
+ npm install -g api-check-mcp
18
+ ```
19
+
20
+ 或者直接通过 `npx` 使用:
21
+
22
+ ```bash
23
+ npx api-check-mcp
24
+ ```
25
+
26
+ ## MCP 配置示例
27
+
28
+ 在 MCP 客户端配置中添加:
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "api-check": {
34
+ "command": "npx",
35
+ "args": ["-y", "api-check-mcp"]
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ ## 可用工具
42
+
43
+ ### `check_api_usage`
44
+
45
+ 扫描指定项目中的接口使用情况。
46
+
47
+ 参数:
48
+
49
+ - `rootDir`: 需要扫描的项目根目录
50
+
51
+ 示例:
52
+
53
+ ```json
54
+ {
55
+ "rootDir": "/Users/yourname/project"
56
+ }
57
+ ```
58
+
59
+ ## 项目中需要提供的文件
60
+
61
+ 在被扫描项目根目录下创建 `api-exclude.md`。
62
+
63
+ ### 仅扫描当前代码
64
+
65
+ ````md
66
+ # 接口排查
67
+
68
+ ## 条件
69
+
70
+ 需要排查的接口列表
71
+
72
+ ```
73
+ expense/report
74
+ expense/page
75
+ secure/expense/page
76
+ ```
77
+ ````
78
+
79
+ ### 按版本扫描
80
+
81
+ ````md
82
+ # 接口排查
83
+
84
+ ## 条件
85
+
86
+ commit列表:
87
+
88
+ ```json
89
+ [
90
+ { "version": "1.22.1", "build": 5221 },
91
+ { "version": "1.22.2", "build": 5224 }
92
+ ]
93
+ ```
94
+
95
+ 需要排查的接口列表
96
+
97
+ ```
98
+ expense/report
99
+ expense/page
100
+ secure/expense/page
101
+ ```
102
+ ````
103
+
104
+ ## 输出结果
105
+
106
+ 扫描完成后,会在项目根目录生成一个结果文件,例如:
107
+
108
+ ```bash
109
+ api-1700000000000.md
110
+ ```
111
+
112
+ 内容示例:
113
+
114
+ ```md
115
+ | 版本 | 接口 | 是否使用 | 引用位置 | 备注 |
116
+ |------|------|----------|----------|------|
117
+ | 1.22.1 | secure/expense/page | ✅ | src/screens/finance/expense/index.tsx:106 | - |
118
+ | 1.22.1 | expense/page | ❌ | - | - |
119
+ ```
120
+
121
+ 如果没有版本列表,则输出格式为:
122
+
123
+ ```md
124
+ | 接口 | 是否使用 | 引用位置 | 备注 |
125
+ |------|----------|----------|------|
126
+ | secure/expense/info | ❌ | src/services/expense.ts:9 | 未发现页面引用该方法 |
127
+ ```
128
+
129
+ ## 使用说明
130
+
131
+ 扫描逻辑大致如下:
132
+
133
+ - 优先扫描 `src/services/` 下的 service 文件
134
+ - 优先识别 `src/common/network/` 等约定目录
135
+ - 通过 import 关系和字符串匹配查找接口引用
136
+ - 如果接口仅存在于 service 方法中,但没有业务调用,会标记为未实际使用
137
+
138
+ ## 注意事项
139
+
140
+ - 被扫描项目通常需要包含 `src` 目录
141
+ - 如果配置了 commit 列表,项目必须是 Git 仓库
142
+ - 扫描结果依赖项目中的接口写法和目录约定,特殊封装场景可能需要人工复核
143
+
144
+ ## License
145
+
146
+ MIT
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@caixm/api-check-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for scanning API usage in frontend projects",
5
+ "type": "module",
6
+ "bin": {
7
+ "api-check-mcp": "server.js"
8
+ },
9
+ "files": [
10
+ "server.js"
11
+ ],
12
+ "keywords": [
13
+ "mcp",
14
+ "model-context-protocol",
15
+ "api-check"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "jinkai123",
19
+ "engines": {
20
+ "node": ">=18"
21
+ }
22
+ }
package/server.js ADDED
@@ -0,0 +1,785 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ const JSONRPC_VERSION = "2.0";
9
+ const PROTOCOL_VERSION = "2024-11-05";
10
+ const CODE_EXTENSIONS = new Set([".js", ".ts", ".jsx", ".tsx"]);
11
+
12
+ function log(...args) {
13
+ process.stderr.write(`[${new Date().toISOString()}] ${args.join(" ")}\n`);
14
+ }
15
+
16
+ function send(message) {
17
+ const body = Buffer.from(JSON.stringify(message), "utf-8");
18
+ process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
19
+ process.stdout.write(body);
20
+ }
21
+
22
+ function sendResult(id, result) {
23
+ if (id === undefined) return;
24
+ send({ jsonrpc: JSONRPC_VERSION, id, result });
25
+ }
26
+
27
+ function sendError(id, code, message, data) {
28
+ if (id === undefined) return;
29
+ const error = { code, message };
30
+ if (data !== undefined) error.data = data;
31
+ send({ jsonrpc: JSONRPC_VERSION, id, error });
32
+ }
33
+
34
+ function isGitRepo(root) {
35
+ try {
36
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, stdio: "pipe" });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function getCommitByBuild(root, build) {
44
+ const list = execFileSync("git", ["rev-list", "--reverse", "HEAD"], { cwd: root, stdio: "pipe" })
45
+ .toString()
46
+ .trim()
47
+ .split("\n")
48
+ .filter(Boolean);
49
+
50
+ if (build < 1 || build > list.length) {
51
+ throw new Error(`build ${build} 超出 commit 范围`);
52
+ }
53
+ return list[build - 1];
54
+ }
55
+
56
+ function createTempWorktree(root, hash) {
57
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "api-check-"));
58
+ execFileSync("git", ["worktree", "add", "--detach", tempRoot, hash], { cwd: root, stdio: "pipe" });
59
+ return tempRoot;
60
+ }
61
+
62
+ function removeTempWorktree(root, worktreePath) {
63
+ try {
64
+ execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: root, stdio: "pipe" });
65
+ } catch {
66
+ // Ignore cleanup errors so the tool can still return the scan result.
67
+ }
68
+
69
+ try {
70
+ fs.rmSync(worktreePath, { recursive: true, force: true });
71
+ } catch {
72
+ // Ignore cleanup errors for temporary directories.
73
+ }
74
+ }
75
+
76
+ function walk(dir) {
77
+ let results = [];
78
+ const list = fs.readdirSync(dir);
79
+
80
+ for (const file of list) {
81
+ const full = path.join(dir, file);
82
+ if (full.includes("node_modules")) continue;
83
+
84
+ const stat = fs.statSync(full);
85
+ if (stat.isDirectory()) {
86
+ results = results.concat(walk(full));
87
+ continue;
88
+ }
89
+
90
+ if (CODE_EXTENSIONS.has(path.extname(full))) {
91
+ results.push(full);
92
+ }
93
+ }
94
+
95
+ return results;
96
+ }
97
+
98
+ function normalizePath(filePath) {
99
+ return filePath.replace(/\\/g, "/");
100
+ }
101
+
102
+ function escapeRegExp(text) {
103
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
104
+ }
105
+
106
+ function escapeTableCell(text) {
107
+ return text.replace(/\|/g, "\\|");
108
+ }
109
+
110
+ function stripCommentsPreserveStrings(content) {
111
+ let result = "";
112
+ let state = "code";
113
+
114
+ // 用简单状态机剥离注释,但保留字符串内容与换行位置,
115
+ // 这样后续做正则搜索时既能避开注释误判,又不会破坏行号映射。
116
+ for (let i = 0; i < content.length; i += 1) {
117
+ const char = content[i];
118
+ const next = content[i + 1];
119
+
120
+ if (state === "lineComment") {
121
+ if (char === "\n") {
122
+ state = "code";
123
+ result += "\n";
124
+ } else {
125
+ result += " ";
126
+ }
127
+ continue;
128
+ }
129
+
130
+ if (state === "blockComment") {
131
+ if (char === "*" && next === "/") {
132
+ result += " ";
133
+ i += 1;
134
+ state = "code";
135
+ } else {
136
+ result += char === "\n" ? "\n" : " ";
137
+ }
138
+ continue;
139
+ }
140
+
141
+ if (state === "singleQuote") {
142
+ result += char;
143
+ if (char === "\\") {
144
+ result += content[i + 1] || "";
145
+ i += 1;
146
+ } else if (char === "'") {
147
+ state = "code";
148
+ }
149
+ continue;
150
+ }
151
+
152
+ if (state === "doubleQuote") {
153
+ result += char;
154
+ if (char === "\\") {
155
+ result += content[i + 1] || "";
156
+ i += 1;
157
+ } else if (char === "\"") {
158
+ state = "code";
159
+ }
160
+ continue;
161
+ }
162
+
163
+ if (state === "template") {
164
+ result += char;
165
+ if (char === "\\") {
166
+ result += content[i + 1] || "";
167
+ i += 1;
168
+ } else if (char === "`") {
169
+ state = "code";
170
+ }
171
+ continue;
172
+ }
173
+
174
+ if (char === "/" && next === "/") {
175
+ result += " ";
176
+ i += 1;
177
+ state = "lineComment";
178
+ continue;
179
+ }
180
+
181
+ if (char === "/" && next === "*") {
182
+ result += " ";
183
+ i += 1;
184
+ state = "blockComment";
185
+ continue;
186
+ }
187
+
188
+ if (char === "'") {
189
+ state = "singleQuote";
190
+ result += char;
191
+ continue;
192
+ }
193
+
194
+ if (char === "\"") {
195
+ state = "doubleQuote";
196
+ result += char;
197
+ continue;
198
+ }
199
+
200
+ if (char === "`") {
201
+ state = "template";
202
+ result += char;
203
+ continue;
204
+ }
205
+
206
+ result += char;
207
+ }
208
+
209
+ return result;
210
+ }
211
+
212
+ function getRelativePath(root, filePath) {
213
+ return normalizePath(path.relative(root, filePath));
214
+ }
215
+
216
+ function isPreferredServiceFile(root, filePath) {
217
+ return getRelativePath(root, filePath).startsWith("src/services/");
218
+ }
219
+
220
+ function isPreferredCommonNetworkFile(root, filePath) {
221
+ return getRelativePath(root, filePath).startsWith("src/common/network/");
222
+ }
223
+
224
+ function getLineMatches(content, sanitizedContent, api) {
225
+ const rawLines = content.split(/\r?\n/);
226
+ const cleanLines = sanitizedContent.split(/\r?\n/);
227
+ const pattern = new RegExp(`(["'\`])${escapeRegExp(api)}(?=["'\`?])`);
228
+ const matches = [];
229
+
230
+ cleanLines.forEach((line, index) => {
231
+ if (!pattern.test(line)) return;
232
+ matches.push({
233
+ lineNumber: index + 1,
234
+ lineText: rawLines[index].trim()
235
+ });
236
+ });
237
+
238
+ return matches;
239
+ }
240
+
241
+ function formatRef(relativePath, lineNumber) {
242
+ return escapeTableCell(`${relativePath}:${lineNumber}`);
243
+ }
244
+
245
+ function findNearestExportName(lines, lineNumber) {
246
+ for (let index = lineNumber - 1; index >= 0; index -= 1) {
247
+ const line = lines[index];
248
+ let match = line.match(/^\s*export\s+const\s+([A-Za-z0-9_$]+)/);
249
+ if (match) return { name: match[1], lineNumber: index + 1 };
250
+
251
+ match = line.match(/^\s*export\s+async\s+function\s+([A-Za-z0-9_$]+)/);
252
+ if (match) return { name: match[1], lineNumber: index + 1 };
253
+
254
+ match = line.match(/^\s*export\s+function\s+([A-Za-z0-9_$]+)/);
255
+ if (match) return { name: match[1], lineNumber: index + 1 };
256
+ }
257
+
258
+ return null;
259
+ }
260
+
261
+ function resolveImportPath(root, fromFile, importSource) {
262
+ let candidateBase = null;
263
+
264
+ // 这里只处理项目内可静态解析的导入。
265
+ // 第三方包导入直接跳过,因为它们不可能指向业务 service 文件。
266
+ if (importSource.startsWith("@src/")) {
267
+ candidateBase = path.join(root, "src", importSource.slice("@src/".length));
268
+ } else if (importSource.startsWith("@/")) {
269
+ candidateBase = path.join(root, "src", importSource.slice(2));
270
+ } else if (importSource.startsWith(".")) {
271
+ candidateBase = path.resolve(path.dirname(fromFile), importSource);
272
+ } else {
273
+ return null;
274
+ }
275
+
276
+ const candidates = [
277
+ candidateBase,
278
+ `${candidateBase}.ts`,
279
+ `${candidateBase}.tsx`,
280
+ `${candidateBase}.js`,
281
+ `${candidateBase}.jsx`,
282
+ path.join(candidateBase, "index.ts"),
283
+ path.join(candidateBase, "index.tsx"),
284
+ path.join(candidateBase, "index.js"),
285
+ path.join(candidateBase, "index.jsx")
286
+ ];
287
+
288
+ return candidates.find(candidate => fs.existsSync(candidate)) || null;
289
+ }
290
+
291
+ function parseImportBindings(specifier) {
292
+ const info = {
293
+ defaultImport: null,
294
+ namespaceImport: null,
295
+ namedImports: new Map()
296
+ };
297
+
298
+ const trimmed = specifier.trim();
299
+ const braceStart = trimmed.indexOf("{");
300
+ const braceEnd = trimmed.lastIndexOf("}");
301
+
302
+ if (trimmed.startsWith("* as ")) {
303
+ info.namespaceImport = trimmed.slice(5).trim();
304
+ return info;
305
+ }
306
+
307
+ if (braceStart >= 0 && braceEnd >= braceStart) {
308
+ const beforeBrace = trimmed.slice(0, braceStart).replace(/,$/, "").trim();
309
+ if (beforeBrace) {
310
+ info.defaultImport = beforeBrace.replace(/^type\s+/, "").trim();
311
+ }
312
+
313
+ const namedBlock = trimmed.slice(braceStart + 1, braceEnd);
314
+ namedBlock
315
+ .split(",")
316
+ .map(part => part.trim())
317
+ .filter(Boolean)
318
+ .forEach(part => {
319
+ const cleaned = part.replace(/^type\s+/, "");
320
+ const [imported, local] = cleaned.split(/\s+as\s+/);
321
+ info.namedImports.set(imported.trim(), (local || imported).trim());
322
+ });
323
+
324
+ return info;
325
+ }
326
+
327
+ if (trimmed) {
328
+ info.defaultImport = trimmed.replace(/^type\s+/, "").trim();
329
+ }
330
+
331
+ return info;
332
+ }
333
+
334
+ function parseImports(root, filePath, sanitizedContent) {
335
+ const imports = [];
336
+ const importPattern = /import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
337
+ let match;
338
+
339
+ // 先把 import 关系建索引,后面追踪“业务文件是否调用某个 service 导出”时
340
+ // 就不需要全量做 AST 分析,靠 import + 文本匹配即可覆盖当前场景。
341
+ while ((match = importPattern.exec(sanitizedContent))) {
342
+ const importSource = match[2];
343
+ const resolvedPath = resolveImportPath(root, filePath, importSource);
344
+ if (!resolvedPath) continue;
345
+
346
+ imports.push({
347
+ source: importSource,
348
+ resolvedPath: normalizePath(resolvedPath),
349
+ ...parseImportBindings(match[1])
350
+ });
351
+ }
352
+
353
+ return imports;
354
+ }
355
+
356
+ function uniqueRefs(refs) {
357
+ return [...new Set(refs)].sort();
358
+ }
359
+
360
+ function getScanFileGroups(root, files) {
361
+ // 扫描时优先收敛到约定目录,减少无关文件带来的误报;
362
+ // 若项目不符合该目录约定,再退化为扫描全部代码文件。
363
+ const preferredCommonFiles = files.filter(file => isPreferredCommonNetworkFile(root, file.filePath));
364
+ const nonCommonFiles = preferredCommonFiles.length
365
+ ? files.filter(file => !isPreferredCommonNetworkFile(root, file.filePath))
366
+ : files;
367
+ const preferredServiceFiles = nonCommonFiles.filter(file => isPreferredServiceFile(root, file.filePath));
368
+
369
+ return {
370
+ serviceFiles: preferredServiceFiles.length ? preferredServiceFiles : nonCommonFiles,
371
+ businessFiles: preferredServiceFiles.length
372
+ ? nonCommonFiles.filter(file => !isPreferredServiceFile(root, file.filePath))
373
+ : nonCommonFiles,
374
+ usedServiceFallback: preferredServiceFiles.length === 0,
375
+ usedCommonFallback: preferredCommonFiles.length === 0
376
+ };
377
+ }
378
+
379
+ function buildFileIndex(root) {
380
+ const srcDir = path.join(root, "src");
381
+ if (!fs.existsSync(srcDir)) throw new Error("未找到 src 目录");
382
+
383
+ const files = walk(srcDir);
384
+ return files.map(filePath => {
385
+ const content = fs.readFileSync(filePath, "utf-8");
386
+ const sanitizedContent = stripCommentsPreserveStrings(content);
387
+
388
+ return {
389
+ filePath,
390
+ relativePath: getRelativePath(root, filePath),
391
+ content,
392
+ sanitizedContent,
393
+ lines: content.split(/\r?\n/),
394
+ // 预先缓存 import 信息,避免每次扫描接口时重复解析文件。
395
+ imports: parseImports(root, filePath, sanitizedContent)
396
+ };
397
+ });
398
+ }
399
+
400
+ function findServiceSymbols(api, serviceFiles) {
401
+ const symbols = [];
402
+
403
+ serviceFiles.forEach(file => {
404
+ const matches = getLineMatches(file.content, file.sanitizedContent, api);
405
+ matches.forEach(match => {
406
+ const nearestExport = findNearestExportName(file.lines, match.lineNumber);
407
+ if (!nearestExport) return;
408
+
409
+ symbols.push({
410
+ serviceFilePath: file.filePath,
411
+ serviceRelativePath: file.relativePath,
412
+ exportName: nearestExport.name,
413
+ definitionLineNumber: nearestExport.lineNumber,
414
+ apiLineNumber: match.lineNumber
415
+ });
416
+ });
417
+ });
418
+
419
+ const deduped = new Map();
420
+ symbols.forEach(symbol => {
421
+ const key = `${symbol.serviceFilePath}::${symbol.exportName}`;
422
+ deduped.set(key, symbol);
423
+ });
424
+ return [...deduped.values()];
425
+ }
426
+
427
+ function findDirectBusinessRefs(api, businessFiles) {
428
+ const refs = [];
429
+
430
+ businessFiles.forEach(file => {
431
+ const matches = getLineMatches(file.content, file.sanitizedContent, api);
432
+ matches.forEach(match => {
433
+ refs.push(formatRef(file.relativePath, match.lineNumber));
434
+ });
435
+ });
436
+
437
+ return refs;
438
+ }
439
+
440
+ function findBusinessCallsForSymbol(symbol, businessFiles) {
441
+ const refs = [];
442
+
443
+ businessFiles.forEach(file => {
444
+ const relatedImports = file.imports.filter(item => item.resolvedPath === normalizePath(symbol.serviceFilePath));
445
+ if (!relatedImports.length) return;
446
+
447
+ const cleanLines = file.sanitizedContent.split(/\r?\n/);
448
+
449
+ relatedImports.forEach(item => {
450
+ const localName = item.namedImports.get(symbol.exportName);
451
+ if (localName) {
452
+ const callPattern = new RegExp(`\\b${escapeRegExp(localName)}\\s*\\(`);
453
+ cleanLines.forEach((line, index) => {
454
+ if (!callPattern.test(line)) return;
455
+ refs.push(formatRef(file.relativePath, index + 1));
456
+ });
457
+ }
458
+
459
+ if (item.namespaceImport) {
460
+ const namespacedPattern = new RegExp(`\\b${escapeRegExp(item.namespaceImport)}\\s*\\.\\s*${escapeRegExp(symbol.exportName)}\\s*\\(`);
461
+ cleanLines.forEach((line, index) => {
462
+ if (!namespacedPattern.test(line)) return;
463
+ refs.push(formatRef(file.relativePath, index + 1));
464
+ });
465
+ }
466
+ });
467
+ });
468
+
469
+ return refs;
470
+ }
471
+
472
+ function findServiceDefinitionRefs(symbols) {
473
+ return symbols.map(symbol => formatRef(symbol.serviceRelativePath, symbol.apiLineNumber));
474
+ }
475
+
476
+ function scanApis(root, apis) {
477
+ const fileIndex = buildFileIndex(root);
478
+ const {
479
+ serviceFiles,
480
+ businessFiles,
481
+ usedServiceFallback,
482
+ usedCommonFallback
483
+ } = getScanFileGroups(root, fileIndex);
484
+ const result = {};
485
+
486
+ if (usedServiceFallback) {
487
+ log("未发现 src/services 目录,回退到全 src 非通用文件推断接口定义");
488
+ }
489
+
490
+ if (usedCommonFallback) {
491
+ log("未发现 src/common/network 目录,回退到全 src 文件中查找业务调用");
492
+ }
493
+
494
+ apis.forEach(api => {
495
+ const refs = [];
496
+ const businessRefs = findDirectBusinessRefs(api, businessFiles);
497
+ refs.push(...businessRefs);
498
+
499
+ const symbols = findServiceSymbols(api, serviceFiles);
500
+ let serviceCallRefs = [];
501
+ symbols.forEach(symbol => {
502
+ serviceCallRefs = serviceCallRefs.concat(findBusinessCallsForSymbol(symbol, businessFiles));
503
+ });
504
+ refs.push(...serviceCallRefs);
505
+
506
+ let note = "";
507
+ const actualUsageRefs = uniqueRefs([...businessRefs, ...serviceCallRefs]);
508
+ if (!refs.length) {
509
+ refs.push(...findServiceDefinitionRefs(symbols));
510
+ if (symbols.length) {
511
+ note = "未发现页面引用该方法";
512
+ }
513
+ }
514
+
515
+ const unique = uniqueRefs(refs);
516
+ result[api] = {
517
+ used: actualUsageRefs.length > 0,
518
+ refs: unique,
519
+ note
520
+ };
521
+ });
522
+
523
+ return result;
524
+ }
525
+
526
+ function parseConfig(root) {
527
+ const file = path.join(root, "api-exclude.md");
528
+ if (!fs.existsSync(file)) throw new Error("未找到 api-exclude.md");
529
+
530
+ const content = fs.readFileSync(file, "utf-8");
531
+ const blocks = [...content.matchAll(/```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g)].map(match => ({
532
+ lang: (match[1] || "").trim().toLowerCase(),
533
+ body: match[2].trim()
534
+ }));
535
+
536
+ if (!blocks.length) throw new Error("未找到接口列表");
537
+
538
+ let commits = [];
539
+ let apiBlock = null;
540
+
541
+ blocks.forEach(block => {
542
+ if (apiBlock) return;
543
+
544
+ // 约定第一个满足结构的 json 代码块为版本列表;
545
+ // 剩余的首个非空代码块视为接口列表,兼容文档里同时放示例和说明文本。
546
+ if (block.lang === "json") {
547
+ try {
548
+ const parsed = JSON.parse(block.body);
549
+ const isCommitList = Array.isArray(parsed) && parsed.every(item =>
550
+ item &&
551
+ typeof item === "object" &&
552
+ typeof item.version === "string" &&
553
+ typeof item.build === "number"
554
+ );
555
+
556
+ if (isCommitList) {
557
+ commits = parsed;
558
+ return;
559
+ }
560
+ } catch {
561
+ // Ignore invalid json block here and continue finding API list.
562
+ }
563
+ }
564
+
565
+ apiBlock = block.body;
566
+ });
567
+
568
+ if (!apiBlock) {
569
+ const apiCandidate = blocks.find(block => block.body);
570
+ apiBlock = apiCandidate ? apiCandidate.body : "";
571
+ }
572
+
573
+ const apis = apiBlock
574
+ .split("\n")
575
+ .map(item => item.trim())
576
+ .filter(Boolean);
577
+
578
+ if (!apis.length) throw new Error("未找到接口列表");
579
+
580
+ return { commits, apis, hasCommitList: commits.length > 0 };
581
+ }
582
+
583
+ function generateMarkdown(results, root, options = {}) {
584
+ const ts = Date.now();
585
+ const file = path.join(root, `api-${ts}.md`);
586
+ const includeVersion = options.includeVersion ?? true;
587
+
588
+ let md = includeVersion
589
+ ? "| 版本 | 接口 | 是否使用 | 引用位置 | 备注 |\n|------|------|----------|----------|------|\n"
590
+ : "| 接口 | 是否使用 | 引用位置 | 备注 |\n|------|----------|----------|------|\n";
591
+
592
+ results.forEach(item => {
593
+ const refs = item.refs.length ? item.refs.join("<br/>") : "-";
594
+ const note = item.note || "-";
595
+ md += includeVersion
596
+ ? `| ${item.version} | ${item.api} | ${item.used ? "✅" : "❌"} | ${refs} | ${note} |\n`
597
+ : `| ${item.api} | ${item.used ? "✅" : "❌"} | ${refs} | ${note} |\n`;
598
+ });
599
+
600
+ fs.writeFileSync(file, md, "utf-8");
601
+ return file;
602
+ }
603
+
604
+ async function runCheck(root) {
605
+ log("解析 api-exclude.md");
606
+ const { commits, apis, hasCommitList } = parseConfig(root);
607
+ log(`找到 ${commits.length} 个 commit, ${apis.length} 个接口`);
608
+
609
+ const finalResults = [];
610
+
611
+ if (hasCommitList) {
612
+ if (!isGitRepo(root)) {
613
+ throw new Error("api-exclude.md 中存在 commit 列表,但当前项目不是 git 项目,无法按版本扫描");
614
+ }
615
+
616
+ log("是 git 项目,基于当前分支历史检查");
617
+
618
+ // 每个版本通过临时 worktree 回到对应 commit 扫描,
619
+ // 避免直接切换当前工作区,减少对开发环境的干扰。
620
+ for (const commitInfo of commits) {
621
+ const hash = getCommitByBuild(root, commitInfo.build);
622
+ log(`在临时 worktree 检查 commit ${commitInfo.version} (${hash})`);
623
+ const worktreePath = createTempWorktree(root, hash);
624
+
625
+ try {
626
+ const scan = scanApis(worktreePath, apis);
627
+ apis.forEach(api => {
628
+ log(`接口 ${api} 使用状态: ${scan[api].used ? "✅" : "❌"}`);
629
+ finalResults.push({
630
+ version: commitInfo.version,
631
+ api,
632
+ used: scan[api].used,
633
+ refs: scan[api].refs,
634
+ note: scan[api].note
635
+ });
636
+ });
637
+ } finally {
638
+ removeTempWorktree(root, worktreePath);
639
+ }
640
+ }
641
+ } else {
642
+ log("未提供 commit 列表,按当前项目代码直接扫描");
643
+ const scan = scanApis(root, apis);
644
+ apis.forEach(api => {
645
+ finalResults.push({
646
+ api,
647
+ used: scan[api].used,
648
+ refs: scan[api].refs,
649
+ note: scan[api].note
650
+ });
651
+ });
652
+ }
653
+
654
+ const file = generateMarkdown(finalResults, root, { includeVersion: hasCommitList });
655
+ log(`输出结果文件: ${file}`);
656
+ return file;
657
+ }
658
+
659
+ async function handleRequest(message) {
660
+ if (!message || typeof message !== "object") return;
661
+
662
+ // 兼容不同客户端的 initialized 通知写法;这类通知无需响应。
663
+ if (message.method === "notifications/initialized" || message.method === "initialized") {
664
+ return;
665
+ }
666
+
667
+ if (message.method === "ping") {
668
+ sendResult(message.id, {});
669
+ return;
670
+ }
671
+
672
+ if (message.method === "initialize") {
673
+ sendResult(message.id, {
674
+ protocolVersion: PROTOCOL_VERSION,
675
+ serverInfo: { name: "api-check-mcp", version: "1.0.0" },
676
+ capabilities: { tools: {} }
677
+ });
678
+ return;
679
+ }
680
+
681
+ if (message.method === "tools/list") {
682
+ sendResult(message.id, {
683
+ tools: [
684
+ {
685
+ name: "check_api_usage",
686
+ description: "扫描指定项目中接口在业务逻辑里的实际调用位置",
687
+ inputSchema: {
688
+ type: "object",
689
+ properties: {
690
+ rootDir: { type: "string", description: "需要扫描的项目根目录" }
691
+ },
692
+ required: ["rootDir"]
693
+ }
694
+ }
695
+ ]
696
+ });
697
+ return;
698
+ }
699
+
700
+ if (message.method === "tools/call") {
701
+ const params = message.params || {};
702
+ if (params.name !== "check_api_usage") {
703
+ sendError(message.id, -32601, `未知工具: ${params.name || "undefined"}`);
704
+ return;
705
+ }
706
+
707
+ if (!params.arguments || typeof params.arguments.rootDir !== "string" || !params.arguments.rootDir.trim()) {
708
+ sendError(message.id, -32602, "rootDir 必须是非空字符串");
709
+ return;
710
+ }
711
+
712
+ try {
713
+ const file = await runCheck(params.arguments.rootDir);
714
+ sendResult(message.id, {
715
+ content: [{ type: "text", text: `✅ 完成,结果文件:${file}` }]
716
+ });
717
+ } catch (error) {
718
+ sendResult(message.id, {
719
+ isError: true,
720
+ content: [{ type: "text", text: `❌ 扫描失败: ${error.message}` }]
721
+ });
722
+ }
723
+ return;
724
+ }
725
+
726
+ if (message.id !== undefined) {
727
+ sendError(message.id, -32601, `不支持的方法: ${message.method || "undefined"}`);
728
+ }
729
+ }
730
+
731
+ function parseHeaders(headerText) {
732
+ const headers = {};
733
+ headerText.split("\r\n").forEach(line => {
734
+ const separator = line.indexOf(":");
735
+ if (separator === -1) return;
736
+ const key = line.slice(0, separator).trim().toLowerCase();
737
+ const value = line.slice(separator + 1).trim();
738
+ headers[key] = value;
739
+ });
740
+ return headers;
741
+ }
742
+
743
+ let buffer = Buffer.alloc(0);
744
+
745
+ async function processBuffer() {
746
+ while (true) {
747
+ const headerEnd = buffer.indexOf("\r\n\r\n");
748
+ if (headerEnd === -1) return;
749
+
750
+ const headerText = buffer.slice(0, headerEnd).toString("utf-8");
751
+ const headers = parseHeaders(headerText);
752
+ const contentLength = Number(headers["content-length"]);
753
+
754
+ if (!Number.isInteger(contentLength) || contentLength < 0) {
755
+ throw new Error("缺少合法的 Content-Length");
756
+ }
757
+
758
+ const totalLength = headerEnd + 4 + contentLength;
759
+ if (buffer.length < totalLength) return;
760
+
761
+ // stdin 是流式输入,一次 data 事件可能带来半包、整包或多包,
762
+ // 所以这里按 Content-Length 手动拆包,并把剩余字节留给下一轮继续处理。
763
+ const body = buffer.slice(headerEnd + 4, totalLength).toString("utf-8");
764
+ buffer = buffer.slice(totalLength);
765
+
766
+ let message;
767
+ try {
768
+ message = JSON.parse(body);
769
+ } catch {
770
+ log("收到无法解析的 JSON 请求");
771
+ continue;
772
+ }
773
+
774
+ await handleRequest(message);
775
+ }
776
+ }
777
+
778
+ process.stdin.on("data", async chunk => {
779
+ buffer = Buffer.concat([buffer, chunk]);
780
+ try {
781
+ await processBuffer();
782
+ } catch (error) {
783
+ log(`处理请求失败: ${error.message}`);
784
+ }
785
+ });