@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.
- package/README.md +146 -0
- package/package.json +22 -0
- 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
|
+
});
|