mcp-read-file-server 1.3.0 → 1.5.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 +69 -34
- package/SKILL.md +19 -8
- package/index.js +742 -79
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
电脑安装了文件加密软件(如天锐绿盾、IP-Guard、亿赛通、深信服等),磁盘上的文件是密文。AI Agent(Claude Code、Cursor、Windsurf、Cline 等)是独立进程,内置文件工具不在白名单内,只能读到密文。而 Node.js 进程在白名单内,通过 MCP Server 提供的替代工具可以正常读写明文。
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
**前提条件:Node.js 进程已被加密软件列为白名单(受信任进程)。**
|
|
12
13
|
|
|
13
14
|
## 支持的 AI Agent
|
|
@@ -38,8 +39,7 @@ mcp-read-file-server/
|
|
|
38
39
|
├── SKILL.md # 配套 Skill(可选,让 AI 学会自动选用本工具)
|
|
39
40
|
├── index.js # MCP Server 主程序(含 shebang,可作可执行入口)
|
|
40
41
|
├── package.json # 包配置(bin/files/依赖声明,可 npm publish)
|
|
41
|
-
├──
|
|
42
|
-
├── .gitignore # Git 忽略规则
|
|
42
|
+
├── .gitignore # Git 忽略规则
|
|
43
43
|
└── node_modules/ # 依赖(@modelcontextprotocol/sdk、zod,不随包发布)
|
|
44
44
|
```
|
|
45
45
|
|
|
@@ -57,7 +57,7 @@ mcp-read-file-server/
|
|
|
57
57
|
npx -y mcp-read-file-server
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
首次运行 npx
|
|
60
|
+
首次运行 npx 会自动下载本包及其依赖到临时目录并启动(需数秒~十几秒;若 Agent 启动超时,可先在终端手动跑一次 `npx -y mcp-read-file-server` 预热缓存,看到卡住等输入后 `Ctrl+C` 退出)。配置 Agent 时将 `command` 设为 `npx`、`args` 设为 `["-y", "mcp-read-file-server"]` 即可(见下文「配置」;**Windows 下部分 Agent 需用 `npx.cmd`**)。
|
|
61
61
|
|
|
62
62
|
### 方式二:从源码运行(开发 / 离线场景)
|
|
63
63
|
|
|
@@ -88,6 +88,21 @@ npm install
|
|
|
88
88
|
}
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
> **⚠️ Windows 用户注意**:部分 Agent(Cursor / Cline / Continue 等)在 Windows 下直接用 `npx` 会启动失败(报 `spawn npx ENOENT` 或连不上),需把 `command` 改成 `npx.cmd`:
|
|
92
|
+
>
|
|
93
|
+
> ```json
|
|
94
|
+
> {
|
|
95
|
+
> "mcpServers": {
|
|
96
|
+
> "read-file-server": {
|
|
97
|
+
> "command": "npx.cmd",
|
|
98
|
+
> "args": ["-y", "mcp-read-file-server"]
|
|
99
|
+
> }
|
|
100
|
+
> }
|
|
101
|
+
> }
|
|
102
|
+
> ```
|
|
103
|
+
>
|
|
104
|
+
> Claude Code 通常能自动识别 `npx`,无需改。若 `npx.cmd` 仍失败,可改用 `"command": "cmd"`、`"args": ["/c", "npx", "-y", "mcp-read-file-server"]`。
|
|
105
|
+
|
|
91
106
|
### 本地源码方式
|
|
92
107
|
|
|
93
108
|
若用「方式二」从源码运行,则指向本地 `index.js`:
|
|
@@ -163,15 +178,20 @@ claude mcp list
|
|
|
163
178
|
|
|
164
179
|
| 工具名 | 替代内置 | 功能 | 参数 |
|
|
165
180
|
|--------|---------|------|------|
|
|
166
|
-
| `read_file` | Read |
|
|
167
|
-
| `read_files` | 多次 Read |
|
|
181
|
+
| `read_file` | Read | 读取单个文件明文(超大文件自动截断) | `path` |
|
|
182
|
+
| `read_files` | 多次 Read | 批量读取多个文件明文(数组或逗号分隔字符串) | `paths` |
|
|
168
183
|
| `read_file_partial` | Read(局部) | 局部读取文件(前N字符 / 指定行范围) | `path`、`mode`、`charCount`、`startLine`、`endLine` |
|
|
169
|
-
| `write_file` | Write |
|
|
170
|
-
| `edit_file` | Edit/MultiEdit |
|
|
171
|
-
| `search_files` | Grep |
|
|
184
|
+
| `write_file` | Write | 写入文件(支持追加模式 / 行尾风格 / BOM 保留) | `path`、`content`、`mode`、`eol` |
|
|
185
|
+
| `edit_file` | Edit/MultiEdit | 精确字符串/正则替换后写回(CRLF/LF 自动兼容、BOM 保留、正则默认多行模式) | `path`、`oldString`、`newString`、`useRegex`、`replaceAll`、`ignoreCase` |
|
|
186
|
+
| `search_files` | Grep | 递归搜索文件内容(支持 `**` 目录通配、跳过二进制/超大文件) | `pattern`、`path`、`include`、`exclude`、`ignoreCase`、`onlyMatching`、`maxResults` |
|
|
187
|
+
| `find_files` | Glob | 按文件名 glob 递归查找(如 `**/*.test.js`) | `pattern`、`path`、`maxResults` |
|
|
188
|
+
| `list_directory` | LS | 列出目录内容(类型/大小/时间) | `path`、`showHidden` |
|
|
189
|
+
| `copy_path` | bash cp | 复制文件/目录(递归;加密环境必须经白名单进程) | `source`、`destination` |
|
|
190
|
+
| `move_path` | bash mv | 移动/重命名(跨盘符自动回退复制+删除) | `source`、`destination` |
|
|
191
|
+
| `remove_path` | bash rm | 删除文件/目录(默认递归,谨慎使用) | `path`、`recursive` |
|
|
172
192
|
| `create_directory` | - | 递归创建目录 | `path` |
|
|
173
|
-
| `file_info` | - |
|
|
174
|
-
| `check_status` | - |
|
|
193
|
+
| `file_info` | - | 查询文件/目录信息(含明文大小、符号链接) | `path` |
|
|
194
|
+
| `check_status` | - | 检查运行状态(可实测解密能力) | `path`(可选) |
|
|
175
195
|
|
|
176
196
|
### `read_file_partial` 参数详解
|
|
177
197
|
|
|
@@ -191,6 +211,27 @@ claude mcp list
|
|
|
191
211
|
|
|
192
212
|
> 返回内容会带文件名、读取范围、总字符数/总行数的头部信息,行模式下每行带行号前缀。超出文件范围时自动截断并提示。
|
|
193
213
|
|
|
214
|
+
### `edit_file` 换行符自动兼容
|
|
215
|
+
|
|
216
|
+
Windows 下文件多为 CRLF 换行,而 AI Agent 生成的多行 `oldString` 通常是 LF 换行,字节级比对会直接失败(报"未找到匹配内容")。本工具已内置兼容逻辑:
|
|
217
|
+
|
|
218
|
+
- **匹配阶段**:先按字节原样精确匹配;未命中时自动将文件与 `oldString` 的换行符统一归一(`\r\n` / `\r` / `\n` 均视为换行)后再匹配,两种风格任意组合均可命中
|
|
219
|
+
- **写入阶段**:`newString` 的行尾会自动转换为文件本身的主导换行风格,不会把 CRLF 文件改写为 LF 混行
|
|
220
|
+
- **提示信息**:触发换行适配时,返回结果会附 `ℹ️ 换行符已自动适配` 说明,方便排查
|
|
221
|
+
- **BOM 自动处理**:UTF-8 BOM 读取时自动剥离、写回时自动补回,`oldString` 无需关心 BOM
|
|
222
|
+
- **正则模式默认多行**:`useRegex=true` 时自动附加 `m` 标志,`^xxx` / `xxx$` 按行锚定
|
|
223
|
+
|
|
224
|
+
注意:该兼容仅针对换行符差异,空格、缩进等其他空白字符仍需与原文完全一致。
|
|
225
|
+
|
|
226
|
+
### 其他内置保护
|
|
227
|
+
|
|
228
|
+
- **预算读取(性能)**:`read_file` / `read_files` / `read_file_partial`(chars 模式) 只读取需要的字节数而非整个文件。读取 100MB 大文件的前 40 万字符从 ~160ms/100MB 内存降到 ~3ms/1.5MB 内存
|
|
229
|
+
- **编码防损坏**:UTF-16 文件(BOM/字节特征检测)直接拒绝读取并提示转换;疑似非 UTF-8(GBK 等,含大量乱码替换字符)的文件 `edit_file` 拒绝编辑写回,防止不可逆损坏
|
|
230
|
+
- **大文件截断**:`read_file` / `read_files` 单文件超过 40 万字符自动截断,提示改用 `read_file_partial` 分页读取,避免撑爆上下文
|
|
231
|
+
- **二进制/超大文件跳过**:`search_files` 只预读首 8KB 判定二进制(图片/exe 含 NUL 字节)后即跳过,超过 5MB 的文件也跳过,并在结果中说明跳过数量
|
|
232
|
+
- **隐藏文件默认跳过**:`search_files` / `find_files` 默认跳过 `.` 开头的文件与目录(避免把 `.env` 等敏感内容灌入上下文),忽略目录还包含 `node_modules`、`.git`、`target`、`build`、`dist`、`vendor` 等;`list_directory` 可用 `showHidden=true` 显示
|
|
233
|
+
- **glob 支持 `{a,b}` 花括号**:`find_files` / `search_files` 的 include 支持 `src/**/*.{ts,tsx}` 这类 Agent 高频写法
|
|
234
|
+
|
|
194
235
|
## 使用
|
|
195
236
|
|
|
196
237
|
配置好后,在 Agent 中直接说需求即可。Agent 会自动调用 MCP 工具读写文件明文。
|
|
@@ -270,30 +311,6 @@ cp SKILL.md ~/.openclaw/skills/encryption-file-ops/SKILL.md
|
|
|
270
311
|
|
|
271
312
|
> 若需离线使用或二次开发,再按「安装 -> 方式二」从源码克隆运行。
|
|
272
313
|
|
|
273
|
-
## 上架 MCP 市场
|
|
274
|
-
|
|
275
|
-
本包已具备 `npx` 直接运行能力,可上架到各 MCP 市场:
|
|
276
|
-
|
|
277
|
-
| 市场 | 上架方式 |
|
|
278
|
-
|------|---------|
|
|
279
|
-
| npm | `npm publish`(包名 `mcp-read-file-server`,已配置 `bin` 与 `files`) |
|
|
280
|
-
| Smithery | 在 https://smithery.ai 提交包名,仓库根已提供 `smithery.yaml`(上架前以官方文档核对) |
|
|
281
|
-
| mcp.so | 在 https://mcp.so 提交 npm 包名与启动命令 `npx -y mcp-read-file-server` |
|
|
282
|
-
| PulseMCP | 在 https://www.pulsemcp.com 提交包名 |
|
|
283
|
-
|
|
284
|
-
发布到 npm 前建议本地预检:
|
|
285
|
-
|
|
286
|
-
```bash
|
|
287
|
-
# 预览将发布到 npm 的文件清单(应只有 index.js / README.md / SKILL.md / LICENSE / package.json)
|
|
288
|
-
npm pack --dry-run
|
|
289
|
-
|
|
290
|
-
# 登录并发布
|
|
291
|
-
npm login
|
|
292
|
-
npm publish
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
发布后,他人即可通过 `npx -y mcp-read-file-server` 一行命令运行,无需手动 `npm install`。
|
|
296
|
-
|
|
297
314
|
## 故障排查
|
|
298
315
|
|
|
299
316
|
### 读取到的仍是密文
|
|
@@ -312,3 +329,21 @@ cd mcp-read-file-server && npm install
|
|
|
312
329
|
# 测试启动
|
|
313
330
|
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node index.js
|
|
314
331
|
```
|
|
332
|
+
|
|
333
|
+
### Agent 连不上 MCP Server
|
|
334
|
+
|
|
335
|
+
包本身正常但 Agent 连不上时,按以下顺序排查:
|
|
336
|
+
|
|
337
|
+
1. **Windows 下 `npx` 找不到**:部分 Agent(Cursor / Cline / Continue 等)需把 `command` 写成 `npx.cmd`,详见上文「配置 -> npx 方式」的 Windows 注意事项。这是 Windows 上最常见的连不上原因。
|
|
338
|
+
2. **首次 npx 下载超时**:npx 首次拉取包需数秒~十几秒,某些 Agent 启动超时较短会连不上。先在终端手动跑一次 `npx -y mcp-read-file-server`(看到卡住等输入即启动成功,`Ctrl+C` 退出),让包进入缓存,再让 Agent 连接即可秒启。
|
|
339
|
+
3. **npx 缓存了旧版 / 损坏**:清缓存重试 ——
|
|
340
|
+
```bash
|
|
341
|
+
npx clear-npx-cache
|
|
342
|
+
# 或 Windows 下手动删除缓存目录
|
|
343
|
+
rm -rf "C:/Users/你的用户名/AppData/Local/npm-cache/_npx"
|
|
344
|
+
```
|
|
345
|
+
4. **确认包本身是否正常**:
|
|
346
|
+
```bash
|
|
347
|
+
npx -y mcp-read-file-server # 能启动=包没问题,问题在 Agent 配置/环境
|
|
348
|
+
```
|
|
349
|
+
能启动并卡住等输入,说明包正常,需检查 Agent 的配置 JSON 格式与 `command` 写法。
|
package/SKILL.md
CHANGED
|
@@ -32,11 +32,16 @@ description: 在文件加密软件(天锐绿盾 / IP-Guard / 亿赛通 / 深
|
|
|
32
32
|
| 写文件 | `Write` | `mcp__read-file-server__write_file` |
|
|
33
33
|
| 精确编辑 | `Edit` / `MultiEdit` | `mcp__read-file-server__edit_file` |
|
|
34
34
|
| 搜索内容 | `Grep` | `mcp__read-file-server__search_files` |
|
|
35
|
+
| 按文件名查找 | `Glob` | `mcp__read-file-server__find_files` |
|
|
36
|
+
| 列目录内容 | `LS` | `mcp__read-file-server__list_directory` |
|
|
37
|
+
| 复制文件/目录 | `Bash cp` | `mcp__read-file-server__copy_path` |
|
|
38
|
+
| 移动/重命名 | `Bash mv` | `mcp__read-file-server__move_path` |
|
|
39
|
+
| 删除文件/目录 | `Bash rm` | `mcp__read-file-server__remove_path` |
|
|
35
40
|
| 创建目录 | (无) | `mcp__read-file-server__create_directory` |
|
|
36
41
|
| 查文件信息 | (无) | `mcp__read-file-server__file_info` |
|
|
37
42
|
| 健康检查 | (无) | `mcp__read-file-server__check_status` |
|
|
38
43
|
|
|
39
|
-
> **强约束**:在加密环境下,**禁止使用** `Read/Write/Edit/MultiEdit/Grep`
|
|
44
|
+
> **强约束**:在加密环境下,**禁止使用** `Read/Write/Edit/MultiEdit/Grep/Glob/LS` 内置工具与 `Bash` 的 `cp/mv/rm` 文件操作--它们会读到密文、写出密文或破坏加密结构。
|
|
40
45
|
|
|
41
46
|
---
|
|
42
47
|
|
|
@@ -81,7 +86,7 @@ description: 在文件加密软件(天锐绿盾 / IP-Guard / 亿赛通 / 深
|
|
|
81
86
|
1. mcp__read-file-server__read_file 读取明文
|
|
82
87
|
2. 分析内容
|
|
83
88
|
3. mcp__read-file-server__edit_file 修改
|
|
84
|
-
- oldString 必须从第 1
|
|
89
|
+
- oldString 必须从第 1 步读到的内容里**原样复制**(含空格、缩进;换行风格差异会自动兼容)
|
|
85
90
|
4. 必要时再 read_file 验证修改结果
|
|
86
91
|
```
|
|
87
92
|
|
|
@@ -132,11 +137,13 @@ description: 在文件加密软件(天锐绿盾 / IP-Guard / 亿赛通 / 深
|
|
|
132
137
|
|
|
133
138
|
1. **路径**:用**绝对路径**最稳(如 `D:/AiJiamiToolsPlugins/...`),相对路径以 MCP Server 启动目录为基准
|
|
134
139
|
2. **`edit_file` 前必读**:必须先 `read_file` 拿到明文,再从原文里**原样复制** `oldString`,否则会因为空格/缩进不匹配而失败
|
|
135
|
-
3.
|
|
140
|
+
3. **换行风格无需担心**:文件是 CRLF 而 `oldString` 是 LF(或相反)时,`edit_file` 会自动归一换行后匹配;`newString` 行尾也会自动跟随文件主导风格,不会产生混行
|
|
136
141
|
4. **`write_file` 是覆盖写**:会清空原文件再写入,重要文件修改前建议先 `read_file` 备份内容
|
|
137
|
-
5. **`search_files` 自动跳过**:`node_modules`、`.git`、`target`、`build`、`dist`、`.
|
|
142
|
+
5. **`search_files` / `find_files` 自动跳过**:`node_modules`、`.git`、`target`、`build`、`dist`、`.svn`、`bin`、`obj`、`out`、`vendor` 与 `.` 开头的隐藏文件/目录;`search_files` 另跳过二进制与超过 5MB 的文件
|
|
138
143
|
6. **大批量搜索**:用 `maxResults` 控制返回数量,避免一次性返回过多结果
|
|
139
|
-
7. **工具调用顺序**:复杂任务先 `check_status
|
|
144
|
+
7. **工具调用顺序**:复杂任务先 `check_status`(可传 path 实测解密)确认 MCP 正常,再正式操作
|
|
145
|
+
8. **`remove_path` 不可恢复**:递归删除前建议先 `list_directory` 确认内容
|
|
146
|
+
9. **`read_files` 路径含逗号时必须传数组**:Windows 路径可合法包含英文逗号,逗号分隔字符串形式会被错误切分
|
|
140
147
|
|
|
141
148
|
---
|
|
142
149
|
|
|
@@ -146,7 +153,11 @@ description: 在文件加密软件(天锐绿盾 / IP-Guard / 亿赛通 / 深
|
|
|
146
153
|
|------|----------|----------|
|
|
147
154
|
| 读到的还是密文/乱码 | Node.js 不在加密软件白名单 | 联系管理员把 `node.exe` 加入白名单 |
|
|
148
155
|
| `mcp__read-file-server__*` 工具全部不可见 | MCP Server 未配置或未启动 | 见 `README.md` 配置 `.mcp.json` |
|
|
149
|
-
| `edit_file` 报"未找到匹配内容" | `oldString`
|
|
156
|
+
| `edit_file` 报"未找到匹配内容" | `oldString` 拼写、缩进不对(换行 CRLF/LF 差异与 BOM 已自动兼容) | 重新 `read_file` 复制原文,**不要凭记忆写**;重点检查空格与缩进 |
|
|
157
|
+
| 读取被拒:文件疑似 UTF-16 | 工具仅支持 UTF-8 | 先转换为 UTF-8 再操作(防乱码与写回损坏) |
|
|
158
|
+
| `edit_file` 拒绝编辑:疑似非 UTF-8(GBK 等) | 按 UTF-8 读出大量乱码替换字符 | 继续写回会不可逆损坏文件;先转码再编辑 |
|
|
159
|
+
| 大文件读取不完整 | 超过 40 万字符自动截断 | 用 `read_file_partial` 分页读取 |
|
|
160
|
+
| `search_files` 搜不到某些文件 | 二进制/超大(>5MB)文件被跳过,或隐藏文件/忽略目录被排除 | 看返回尾部的跳过统计;必要时用 `include` 限定范围 |
|
|
150
161
|
| `edit_file` 报"匹配到 N 处" | 文件中存在重复内容 | 加更长/更唯一的 `oldString` 唯一定位,或 `replaceAll=true` |
|
|
151
162
|
| `search_files` 报"正则表达式无效" | 正则语法错误 | 检查 `pattern` 是否需要转义特殊字符 |
|
|
152
163
|
| `write_file` 报权限错误 | 文件被占用或目录无写权限 | 关闭占用进程 / 检查目录权限 |
|
|
@@ -158,8 +169,8 @@ description: 在文件加密软件(天锐绿盾 / IP-Guard / 亿赛通 / 深
|
|
|
158
169
|
|
|
159
170
|
| 工具类型 | 在加密环境下 | 备注 |
|
|
160
171
|
|----------|--------------|------|
|
|
161
|
-
| 内置 `Read/Write/Edit/Grep` | ❌ 禁用 | 会读到密文或破坏加密 |
|
|
162
|
-
| 内置 `Bash` | ⚠️ 慎用 | Bash 进程通常不在白名单,`cat`/`sed`
|
|
172
|
+
| 内置 `Read/Write/Edit/Grep/Glob/LS` | ❌ 禁用 | 会读到密文或破坏加密 |
|
|
173
|
+
| 内置 `Bash` | ⚠️ 慎用 | Bash 进程通常不在白名单,`cat`/`sed`/`cp`/`mv`/`rm` 也会读到密文或产出密文文件;文件操作一律改用 MCP 工具 |
|
|
163
174
|
| 内置 `Glob` | ✅ 可用 | 只列文件名,不读内容 |
|
|
164
175
|
| 内置 `NotebookEdit` | ⚠️ 慎用 | 同 Edit |
|
|
165
176
|
| `mcp__read-file-server__*` | ✅ 主用 | 本 Skill 推广的工具集 |
|
package/index.js
CHANGED
|
@@ -11,15 +11,20 @@
|
|
|
11
11
|
* 适用于任何「Node.js 是加密软件白名单进程」的场景。
|
|
12
12
|
*
|
|
13
13
|
* 提供工具:
|
|
14
|
-
* - read_file 读取单个文件明文(替代内置 Read
|
|
14
|
+
* - read_file 读取单个文件明文(替代内置 Read,大文件自动截断)
|
|
15
15
|
* - read_files 批量读取多个文件明文
|
|
16
16
|
* - read_file_partial 局部读取文件(前N字符 / 指定行范围)
|
|
17
|
-
* - write_file 写入文件,自动加密落盘(替代内置 Write
|
|
18
|
-
* - edit_file 精确字符串/正则替换后写回(替代内置 Edit/MultiEdit
|
|
19
|
-
* - search_files 递归搜索文件内容(替代内置 Grep
|
|
17
|
+
* - write_file 写入文件,自动加密落盘(替代内置 Write,支持 append / 行尾风格 / BOM 保留)
|
|
18
|
+
* - edit_file 精确字符串/正则替换后写回(替代内置 Edit/MultiEdit,CRLF/LF 自动兼容)
|
|
19
|
+
* - search_files 递归搜索文件内容(替代内置 Grep,支持 ** 目录通配、跳过二进制/超大文件)
|
|
20
|
+
* - find_files 按文件名 glob 递归查找文件(替代内置 Glob)
|
|
21
|
+
* - list_directory 列出目录内容(替代内置 LS)
|
|
22
|
+
* - copy_path 复制文件或目录(替代 bash cp,加密环境必须经白名单进程)
|
|
23
|
+
* - move_path 移动/重命名文件或目录(替代 bash mv)
|
|
24
|
+
* - remove_path 删除文件或目录(替代 bash rm)
|
|
20
25
|
* - create_directory 递归创建目录
|
|
21
26
|
* - file_info 查询文件/目录信息
|
|
22
|
-
* - check_status
|
|
27
|
+
* - check_status 检查工具运行状态(可实测解密能力)
|
|
23
28
|
*/
|
|
24
29
|
const fs = require("fs");
|
|
25
30
|
const path = require("path");
|
|
@@ -31,32 +36,215 @@ const { z } = require("zod");
|
|
|
31
36
|
const pkg = require("./package.json");
|
|
32
37
|
const server = new McpServer({ name: "read-file-server", version: pkg.version });
|
|
33
38
|
|
|
39
|
+
// read_file / read_files 返回内容的安全上限:超过则截断,避免撑爆 Agent 上下文
|
|
40
|
+
const READ_MAX_CHARS = 400000;
|
|
41
|
+
// search_files 单文件扫描上限:超过则跳过该文件(超大日志/minified 产物)
|
|
42
|
+
const SCAN_MAX_BYTES = 5 * 1024 * 1024;
|
|
43
|
+
// search_files 嗅探二进制的采样字节数:首块含 NUL 即视为二进制
|
|
44
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 检测 buffer 是否为 UTF-16 文件(BOM FF FE / FE FF,或前若干字节呈现 NUL 交替特征)。
|
|
48
|
+
* 用于读工具拒绝按 UTF-8 处理 UTF-16 文件(静默乱码 + edit 写回即损坏)。
|
|
49
|
+
*/
|
|
50
|
+
function looksUtf16(buf) {
|
|
51
|
+
if (buf.length >= 2 && ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff))) return true;
|
|
52
|
+
// 无 BOM 启发式:ASCII 内容的 UTF-16LE 呈现「可打印字节与 NUL 交替」
|
|
53
|
+
const len = Math.min(buf.length, 256);
|
|
54
|
+
let pairs = 0, alt = 0;
|
|
55
|
+
for (let i = 0; i + 1 < len; i += 2) {
|
|
56
|
+
pairs++;
|
|
57
|
+
if ((buf[i] !== 0 && buf[i + 1] === 0) || (buf[i] === 0 && buf[i + 1] !== 0)) alt++;
|
|
58
|
+
}
|
|
59
|
+
return pairs >= 4 && alt / pairs > 0.8;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 检测 UTF-8 文本是否含大量替换字符(非法字节序列被解码的产物),
|
|
64
|
+
* 用于 edit_file 拒绝写回疑似非 UTF-8(GBK 等)内容,防止不可逆损坏。
|
|
65
|
+
*/
|
|
66
|
+
function isLikelyNonUtf8(text) {
|
|
67
|
+
if (text.length < 100) return false;
|
|
68
|
+
let bad = 0;
|
|
69
|
+
for (let i = 0; i < text.length; i++) {
|
|
70
|
+
if (text.charCodeAt(i) === 0xfffd) bad++;
|
|
71
|
+
}
|
|
72
|
+
return bad / text.length > 0.01; // >1% 替换字符即判定
|
|
73
|
+
}
|
|
74
|
+
|
|
34
75
|
/**
|
|
35
|
-
*
|
|
76
|
+
* 读取文件明文内容并做大文件与 BOM 处理(预算读取实现)。
|
|
77
|
+
* - maxChars 限制返回字符数:先按 UTF-8 最多 4 字节/字符的关系将预算换算为字节数,
|
|
78
|
+
* 只读需要的字节数;截断时用明文字节数报告真实体量,避免 readFileSync 全量载入
|
|
79
|
+
* - 剥离 UTF-8 BOM 并记录,写回工具据此外决定是否补回,避免 oldString 匹配失败与 BOM 丢失
|
|
80
|
+
* 返回 { ok, content, size, truncated, hasBom, totalChars },失败返回 { ok:false, error }。
|
|
36
81
|
*/
|
|
37
|
-
function readFileContent(filePath) {
|
|
82
|
+
function readFileContent(filePath, maxChars) {
|
|
38
83
|
try {
|
|
39
|
-
const
|
|
40
|
-
//
|
|
41
|
-
|
|
84
|
+
const limit = maxChars || Infinity;
|
|
85
|
+
// UTF-8 变长编码(1-4 字节/字符):预算字节数 = 上限字符数 × 4,保证解码后至少有 limit 个字符。
|
|
86
|
+
// 预算同时兼作 UTF-16 嗅探(首字节特征),一次 IO 完成(无限制场景也先嗅 4KB)
|
|
87
|
+
const budgetBytes = limit === Infinity ? 4096 : limit * 4;
|
|
88
|
+
const pref = readFilePrefix(filePath, budgetBytes);
|
|
89
|
+
if (!pref.ok) return pref;
|
|
90
|
+
// UTF-16 防护:按 UTF-8 解码 UTF-16 文件会产生大量乱码,且 edit_file 写回会损坏
|
|
91
|
+
// 原文件。嗅探首块命中则直接拒绝并给出明确指引。
|
|
92
|
+
if (looksUtf16(pref.firstBytes)) {
|
|
93
|
+
return { ok: false, error: "文件疑似 UTF-16 编码(检测到 UTF-16 BOM 或字节特征),本工具仅支持 UTF-8,请先转换为 UTF-8 再操作: " + filePath };
|
|
94
|
+
}
|
|
95
|
+
let content = pref.text;
|
|
96
|
+
const hasBom = content.charCodeAt(0) === 0xfeff;
|
|
97
|
+
if (hasBom) content = content.slice(1);
|
|
98
|
+
if (!pref.isTruncated) {
|
|
99
|
+
// 整个文件都在预算内:无截断。但字符数可能仍超 limit(预算按4字节/字符放大),
|
|
100
|
+
// 此时按 limit 截断字符(文件已全部读入,totalChars 可精确报告)
|
|
101
|
+
if (limit !== Infinity && content.length > limit) {
|
|
102
|
+
return { ok: true, content: content.slice(0, limit), size: Buffer.byteLength(content, "utf-8"), truncated: true, hasBom, totalChars: content.length };
|
|
103
|
+
}
|
|
104
|
+
const size = Buffer.byteLength(content, "utf-8");
|
|
105
|
+
return { ok: true, content, size, truncated: false, hasBom, totalChars: content.length };
|
|
106
|
+
}
|
|
107
|
+
if (limit === Infinity) {
|
|
108
|
+
// 无限制场景(edit_file 等)预算只是嗅探:文件超 4KB 需全量补读
|
|
109
|
+
content = content + fs.readFileSync(filePath, "utf-8").slice(content.length + (hasBom ? 1 : 0));
|
|
110
|
+
return { ok: true, content, size: Buffer.byteLength(content, "utf-8"), truncated: false, hasBom, totalChars: content.length };
|
|
111
|
+
}
|
|
112
|
+
// 预算内读满仍可能没读全文件:截断到 limit 字符。
|
|
113
|
+
// 预算按「最多4字节/字符」换算,正常文本截断点落在字符边界;若末字符恰为 U+FFFD,
|
|
114
|
+
// 说明字节边界被切断,回退一位丢弃半个字符(多字节 UTF-8 中合法 U+FFFD 极罕见,可接受)
|
|
115
|
+
let end = limit;
|
|
116
|
+
if (end < content.length) {
|
|
117
|
+
const code = content.charCodeAt(end - 1);
|
|
118
|
+
if (code >= 0xd800 && code <= 0xdbff) end++; // 代理对保护
|
|
119
|
+
else if (code === 0xfffd) end = Math.max(1, end - 1);
|
|
120
|
+
}
|
|
121
|
+
content = content.slice(0, end);
|
|
122
|
+
// 明文总字节数:stat.size 是密文字节数不可用;截断场景用已读字节数做下界估计
|
|
123
|
+
return { ok: true, content, size: pref.bytesRead, truncated: true, hasBom, totalChars: null, bytesRead: pref.bytesRead };
|
|
42
124
|
} catch (e) {
|
|
43
125
|
if (e.code === "ENOENT") {
|
|
44
126
|
return { ok: false, error: "文件不存在: " + filePath };
|
|
45
127
|
}
|
|
128
|
+
if (e.code === "EISDIR") {
|
|
129
|
+
return { ok: false, error: "路径是目录而非文件: " + filePath };
|
|
130
|
+
}
|
|
131
|
+
return { ok: false, error: "读取失败(可能是密文,请确认 Node.js 是否被加密软件列为白名单进程): " + e.message };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 将 glob 模式编译为正则:支持 *(不含路径分隔符)、**(跨目录任意字符)、?(单字符)、
|
|
137
|
+
* {a,b} 花括号展开(如 *.{ts,tsx})。统一使用 / 作为路径分隔符(匹配前已把
|
|
138
|
+
* Windows 的 \ 归一),与 Agent 的 glob 习惯一致。
|
|
139
|
+
*/
|
|
140
|
+
function globToRegex(glob) {
|
|
141
|
+
// 先展开花括号 {a,b} -> (a|b),支持一层嵌套场景(**/{src,test}/** 等)
|
|
142
|
+
let expanded = glob;
|
|
143
|
+
const brace = /\{([^{}]*,[^{}]*)\}/;
|
|
144
|
+
let guard = 0;
|
|
145
|
+
while (brace.test(expanded) && guard++ < 10) {
|
|
146
|
+
expanded = expanded.replace(brace, (_m, inner) => "(" + inner.split(",").map((s) => s.trim()).join("|") + ")");
|
|
147
|
+
}
|
|
148
|
+
let re = "";
|
|
149
|
+
for (let i = 0; i < expanded.length; i++) {
|
|
150
|
+
const ch = expanded[i];
|
|
151
|
+
if (ch === "*") {
|
|
152
|
+
if (expanded[i + 1] === "*") {
|
|
153
|
+
// ** 跨目录任意匹配(连同后随的 / 一并吞掉,避免空段)
|
|
154
|
+
re += ".*";
|
|
155
|
+
i++;
|
|
156
|
+
if (expanded[i + 1] === "/") i++;
|
|
157
|
+
} else {
|
|
158
|
+
// * 不跨目录
|
|
159
|
+
re += "[^/]*";
|
|
160
|
+
}
|
|
161
|
+
} else if (ch === "(") {
|
|
162
|
+
// 花括号展开产生的分组 (a|b):整段原样保留到闭括号,跳过其中字符的转义
|
|
163
|
+
const close = expanded.indexOf(")", i);
|
|
164
|
+
if (close === -1) {
|
|
165
|
+
re += "\\(";
|
|
166
|
+
} else {
|
|
167
|
+
// 组内允许含 * 与 ? 通配,递归编译组内每个分支后重组
|
|
168
|
+
const inner = expanded.slice(i + 1, close);
|
|
169
|
+
const branches = inner.split("|").map((b) => globToRegex(b).source.replace(/^\^|\$$/g, ""));
|
|
170
|
+
re += "(" + branches.join("|") + ")";
|
|
171
|
+
i = close;
|
|
172
|
+
}
|
|
173
|
+
} else if (ch === "?") {
|
|
174
|
+
re += "[^/]";
|
|
175
|
+
} else if (/[.+^${}()|[\]\\]/.test(ch)) {
|
|
176
|
+
re += "\\" + ch;
|
|
177
|
+
} else {
|
|
178
|
+
re += ch;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return new RegExp("^" + re + "$");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 判断 buffer 首块是否含二进制特征(NUL 字节),用于 search_files 跳过图片/exe 等。
|
|
186
|
+
*/
|
|
187
|
+
function isBinaryBuffer(buf) {
|
|
188
|
+
const len = Math.min(buf.length, BINARY_SNIFF_BYTES);
|
|
189
|
+
for (let i = 0; i < len; i++) {
|
|
190
|
+
if (buf[i] === 0) return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 预算读取:只读文件前 budgetBytes 字节并按 UTF-8 增量解码。
|
|
197
|
+
* 用于大文件截断与二进制嗅探场景,避免 readFileSync 全量载入(读 1GB 文件
|
|
198
|
+
* 只为返回前 40 万字符的内存浪费)。解码在字节边界截断时,截断的末字符
|
|
199
|
+
* 会退化为 U+FFFD,因此调用方必须显式传入 isTruncated 判定(截断才可信)。
|
|
200
|
+
* 返回 { ok, text, bytesRead, isTruncated, firstBytes },失败返回 { ok:false, error }。
|
|
201
|
+
*/
|
|
202
|
+
function readFilePrefix(filePath, budgetBytes) {
|
|
203
|
+
let fd;
|
|
204
|
+
try {
|
|
205
|
+
fd = fs.openSync(filePath, "r");
|
|
206
|
+
const chunks = [];
|
|
207
|
+
let total = 0;
|
|
208
|
+
const chunk = Buffer.allocUnsafe(Math.min(budgetBytes, 1024 * 1024));
|
|
209
|
+
while (total < budgetBytes) {
|
|
210
|
+
const want = Math.min(chunk.length, budgetBytes - total);
|
|
211
|
+
const n = fs.readSync(fd, chunk, 0, want, null);
|
|
212
|
+
if (n <= 0) break;
|
|
213
|
+
chunks.push(Buffer.from(chunk.subarray(0, n)));
|
|
214
|
+
total += n;
|
|
215
|
+
}
|
|
216
|
+
const buf = Buffer.concat(chunks);
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
text: buf.toString("utf-8"),
|
|
220
|
+
bytesRead: total,
|
|
221
|
+
isTruncated: total >= budgetBytes,
|
|
222
|
+
firstBytes: buf,
|
|
223
|
+
};
|
|
224
|
+
} catch (e) {
|
|
225
|
+
if (e.code === "ENOENT") return { ok: false, error: "文件不存在: " + filePath };
|
|
226
|
+
if (e.code === "EISDIR") return { ok: false, error: "路径是目录而非文件: " + filePath };
|
|
46
227
|
return { ok: false, error: "读取失败(可能是密文,请确认 Node.js 是否被加密软件列为白名单进程): " + e.message };
|
|
228
|
+
} finally {
|
|
229
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) { /* 忽略关闭失败 */ } }
|
|
47
230
|
}
|
|
48
231
|
}
|
|
49
232
|
|
|
50
233
|
// 注册 read_file 工具
|
|
51
234
|
server.tool(
|
|
52
235
|
"read_file",
|
|
53
|
-
"读取指定路径的文件内容(明文)。加密软件环境下,Node.js
|
|
54
|
-
{ path: z.string().describe("
|
|
236
|
+
"读取指定路径的文件内容(明文)。加密软件环境下,Node.js 进程作为白名单可自动解密读取明文。适用于读取代码、配置、文档等文本文件。超大文件自动截断(提示改用 read_file_partial 分页读取)。替代内置 Read 工具。",
|
|
237
|
+
{ path: z.string().describe("文件路径,支持相对路径或绝对路径(相对路径以 MCP Server 启动目录为基准,建议用绝对路径)") },
|
|
55
238
|
{ readOnlyHint: true },
|
|
56
239
|
async ({ path: filePath }) => {
|
|
57
|
-
const result = readFileContent(filePath);
|
|
240
|
+
const result = readFileContent(filePath, READ_MAX_CHARS);
|
|
58
241
|
if (result.ok) {
|
|
59
|
-
|
|
242
|
+
let text = result.content;
|
|
243
|
+
if (result.truncated) {
|
|
244
|
+
// 截断时 totalChars 不可得(避免为报总数而全量读取),用已读明文字节数描述体量
|
|
245
|
+
text += "\n\n⚠️ 文件较大,已截断为前 " + result.content.length + " 字符(至少 " + result.bytesRead + " 字节)。请改用 read_file_partial 分页读取后续内容。";
|
|
246
|
+
}
|
|
247
|
+
return { content: [{ type: "text", text }] };
|
|
60
248
|
} else {
|
|
61
249
|
return { content: [{ type: "text", text: "❌ " + result.error }], isError: true };
|
|
62
250
|
}
|
|
@@ -66,24 +254,40 @@ server.tool(
|
|
|
66
254
|
// 注册 read_files 工具(批量读取)
|
|
67
255
|
server.tool(
|
|
68
256
|
"read_files",
|
|
69
|
-
"
|
|
70
|
-
{
|
|
257
|
+
"批量读取多个文件的内容(明文)。paths 推荐传字符串数组(MCP 原生支持);兼容旧版的英文逗号分隔字符串(注意:Windows 路径可合法包含逗号,含逗号路径必须用数组形式)。单文件超限自动截断。加密软件环境下通过 Node.js 白名单进程自动解密。",
|
|
258
|
+
{
|
|
259
|
+
paths: z.union([z.array(z.string()), z.string()]).describe("文件路径列表:字符串数组(推荐)或英文逗号分隔的字符串(兼容旧版)"),
|
|
260
|
+
},
|
|
71
261
|
{ readOnlyHint: true },
|
|
72
|
-
async ({ paths
|
|
73
|
-
|
|
262
|
+
async ({ paths }) => {
|
|
263
|
+
// 兼容两种入参:数组直接用;字符串按逗号切分(旧版行为,含逗号路径应改用数组)
|
|
264
|
+
const pathList = Array.isArray(paths)
|
|
265
|
+
? paths.map((p) => String(p).trim()).filter(Boolean)
|
|
266
|
+
: String(paths).split(",").map((p) => p.trim()).filter(Boolean);
|
|
74
267
|
if (!pathList.length) {
|
|
75
268
|
return { content: [{ type: "text", text: "❌ 未提供任何文件路径" }], isError: true };
|
|
76
269
|
}
|
|
77
270
|
const results = [];
|
|
271
|
+
let okCount = 0;
|
|
78
272
|
for (const p of pathList) {
|
|
79
|
-
const result = readFileContent(p);
|
|
273
|
+
const result = readFileContent(p, READ_MAX_CHARS);
|
|
80
274
|
if (result.ok) {
|
|
81
|
-
|
|
275
|
+
okCount++;
|
|
276
|
+
let body = result.content;
|
|
277
|
+
if (result.truncated) {
|
|
278
|
+
body += "\n\n⚠️ [单文件已截断为前 " + result.content.length + " 字符,如需后续内容请用 read_file_partial]";
|
|
279
|
+
}
|
|
280
|
+
results.push("========== 文件: " + p + " ==========\n" + body);
|
|
82
281
|
} else {
|
|
83
282
|
results.push("========== 文件: " + p + " 【读取失败】 ==========\n❌ " + result.error);
|
|
84
283
|
}
|
|
85
284
|
}
|
|
86
|
-
|
|
285
|
+
// 全部失败时置错误标记,避免 Agent 误判批量读取成功
|
|
286
|
+
const allFailed = okCount === 0;
|
|
287
|
+
return {
|
|
288
|
+
content: [{ type: "text", text: results.join("\n\n") }],
|
|
289
|
+
...(allFailed ? { isError: true } : {}),
|
|
290
|
+
};
|
|
87
291
|
}
|
|
88
292
|
);
|
|
89
293
|
|
|
@@ -100,20 +304,36 @@ server.tool(
|
|
|
100
304
|
},
|
|
101
305
|
{ readOnlyHint: true },
|
|
102
306
|
async ({ path: filePath, mode, charCount, startLine, endLine }) => {
|
|
103
|
-
|
|
307
|
+
// chars 模式走预算读取(只读需要的字节),lines 模式需完整行结构仍全量读
|
|
308
|
+
const result = mode === "chars"
|
|
309
|
+
? (charCount === undefined
|
|
310
|
+
? null
|
|
311
|
+
: readFileContent(filePath, charCount))
|
|
312
|
+
: readFileContent(filePath);
|
|
313
|
+
if (mode === "chars" && result === null) {
|
|
314
|
+
return { content: [{ type: "text", text: "❌ mode=chars 时必须提供 charCount 参数" }], isError: true };
|
|
315
|
+
}
|
|
104
316
|
if (!result.ok) {
|
|
105
317
|
return { content: [{ type: "text", text: "❌ " + result.error }], isError: true };
|
|
106
318
|
}
|
|
107
319
|
const content = result.content;
|
|
108
|
-
|
|
320
|
+
// chars 模式下预算读取可能已截断在 charCount 处,此时 totalChars 不可知,
|
|
321
|
+
// 报告为「至少」;未截断(文件小于预算)则精确
|
|
322
|
+
const totalChars = mode === "chars" && result.truncated ? null : result.totalChars;
|
|
323
|
+
const totalCharsText = totalChars === null ? "≥" + content.length : String(totalChars);
|
|
109
324
|
|
|
110
325
|
if (mode === "chars") {
|
|
111
|
-
|
|
112
|
-
|
|
326
|
+
let end = charCount;
|
|
327
|
+
// 避免把代理对(emoji/生僻字)切成两半产生孤立代理项乱码:落在高位代理上时右移一位
|
|
328
|
+
if (end < content.length) {
|
|
329
|
+
const code = content.charCodeAt(end - 1);
|
|
330
|
+
if (code >= 0xd800 && code <= 0xdbff) end++;
|
|
113
331
|
}
|
|
114
|
-
const slice = content.slice(0,
|
|
115
|
-
const header = "📄 文件: " + filePath + "\n模式: 前 " + charCount + " 字符(共 " +
|
|
116
|
-
const footer =
|
|
332
|
+
const slice = content.slice(0, end);
|
|
333
|
+
const header = "📄 文件: " + filePath + "\n模式: 前 " + charCount + " 字符(共 " + totalCharsText + " 字符)\n";
|
|
334
|
+
const footer = end < content.length || result.truncated
|
|
335
|
+
? "\n\n...(已截断,还有内容未显示,可用更大的 charCount 继续读取)"
|
|
336
|
+
: "";
|
|
117
337
|
return { content: [{ type: "text", text: header + "──────────────────────\n" + slice + footer }] };
|
|
118
338
|
}
|
|
119
339
|
|
|
@@ -121,13 +341,19 @@ server.tool(
|
|
|
121
341
|
if (startLine === undefined) {
|
|
122
342
|
return { content: [{ type: "text", text: "❌ mode=lines 时必须提供 startLine 参数" }], isError: true };
|
|
123
343
|
}
|
|
124
|
-
|
|
344
|
+
let lines = content.split(/\r?\n/);
|
|
345
|
+
// 文件以换行结尾时 split 产生尾部空元素,与编辑器行号语义不符(100行文件不应显示101行)
|
|
346
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
125
347
|
const totalLines = lines.length;
|
|
126
348
|
const sLine = startLine;
|
|
127
349
|
const eLine = endLine !== undefined ? endLine : startLine;
|
|
128
350
|
if (eLine < sLine) {
|
|
129
351
|
return { content: [{ type: "text", text: "❌ endLine 不能小于 startLine" }], isError: true };
|
|
130
352
|
}
|
|
353
|
+
// 起始行超出总行数时明确报错,而不是返回倒挂的空区间
|
|
354
|
+
if (sLine > totalLines) {
|
|
355
|
+
return { content: [{ type: "text", text: "❌ startLine " + sLine + " 超出文件总行数 " + totalLines + "(文件: " + filePath + ")" }], isError: true };
|
|
356
|
+
}
|
|
131
357
|
// 行号从1开始,数组索引从0开始
|
|
132
358
|
const startIdx = Math.max(0, sLine - 1);
|
|
133
359
|
const endIdx = Math.min(totalLines, eLine); // slice 不含 endIdx,所以用 eLine(因为已经 +1 偏移)
|
|
@@ -148,30 +374,111 @@ server.tool(
|
|
|
148
374
|
// 注册 write_file 工具(加密软件环境下安全写回)
|
|
149
375
|
server.tool(
|
|
150
376
|
"write_file",
|
|
151
|
-
"
|
|
377
|
+
"将内容写入指定路径(明文)。支持追加模式(mode=append)与覆盖模式(默认);覆盖已有文件时行尾风格自动跟随原文件(避免制造混合行尾)、已有 BOM 自动保留。加密软件环境下,Node.js 白名单进程写入会自动加密落盘,适用于安全写回加密文件。替代内置 Write 工具。",
|
|
152
378
|
{
|
|
153
|
-
path: z.string().describe("
|
|
379
|
+
path: z.string().describe("文件路径,支持相对路径或绝对路径(相对路径以 MCP Server 启动目录为基准,建议用绝对路径)"),
|
|
154
380
|
content: z.string().describe("写入的文件内容(明文)"),
|
|
381
|
+
mode: z.enum(["overwrite", "append"]).optional().describe("写入模式:overwrite=覆盖(默认);append=追加到文件末尾"),
|
|
382
|
+
eol: z.enum(["auto", "lf", "crlf"]).optional().describe("行尾风格:auto=跟随已有文件(默认,新文件用 LF);lf=强制 LF;crlf=强制 CRLF"),
|
|
155
383
|
},
|
|
156
|
-
async ({ path: filePath, content }) => {
|
|
384
|
+
async ({ path: filePath, content, mode, eol }) => {
|
|
157
385
|
try {
|
|
386
|
+
const writeMode = mode === "append" ? "append" : "overwrite";
|
|
387
|
+
let finalContent = content;
|
|
388
|
+
// 覆盖/追加已有文件时:读取原文件元信息(BOM 与主导行尾)做适配,
|
|
389
|
+
// 单次 readFileContent(prefix 4KB 嗅探 + 全量) 避免重复 IO
|
|
390
|
+
if (fs.existsSync(filePath)) {
|
|
391
|
+
const prev = readFileContent(filePath);
|
|
392
|
+
if (prev.ok) {
|
|
393
|
+
if (writeMode === "overwrite" && prev.hasBom) finalContent = "\uFEFF" + finalContent;
|
|
394
|
+
if (eol !== "lf" && eol !== "crlf") {
|
|
395
|
+
// eol=auto(默认):内容行尾跟随原文件主导风格(双向转换),
|
|
396
|
+
// 避免 CRLF 文件被追加 LF 内容或 LF 文件被写入 CRLF 内容产生混行
|
|
397
|
+
const prevEol = detectEol(prev.content);
|
|
398
|
+
finalContent = normalizeEol(finalContent, prevEol);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (eol === "lf") finalContent = normalizeEol(finalContent, "\n");
|
|
403
|
+
if (eol === "crlf") finalContent = normalizeEol(finalContent, "\r\n");
|
|
158
404
|
// 自动创建父目录,避免新文件路径不存在时直接报错
|
|
159
405
|
const parent = path.dirname(filePath);
|
|
160
406
|
if (parent && !fs.existsSync(parent)) {
|
|
161
407
|
fs.mkdirSync(parent, { recursive: true });
|
|
162
408
|
}
|
|
163
|
-
fs.writeFileSync(filePath,
|
|
164
|
-
return { content: [{ type: "text", text: "✅
|
|
409
|
+
fs.writeFileSync(filePath, finalContent, { encoding: "utf-8", flag: writeMode === "append" ? "a" : "w" });
|
|
410
|
+
return { content: [{ type: "text", text: "✅ 写入成功" + (writeMode === "append" ? "(追加)" : "") + ": " + filePath }] };
|
|
165
411
|
} catch (e) {
|
|
166
412
|
return { content: [{ type: "text", text: "❌ 写入失败: " + e.message }], isError: true };
|
|
167
413
|
}
|
|
168
414
|
}
|
|
169
415
|
);
|
|
170
416
|
|
|
417
|
+
/**
|
|
418
|
+
* 检测文本的主导换行风格:CRLF 数量多于孤立 LF 时返回 "\r\n",否则返回 "\n"。
|
|
419
|
+
*/
|
|
420
|
+
function detectEol(text) {
|
|
421
|
+
const crlf = (text.match(/\r\n/g) || []).length;
|
|
422
|
+
const lf = (text.match(/\n/g) || []).length - crlf;
|
|
423
|
+
return crlf > lf ? "\r\n" : "\n";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 将文本换行符统一为目标风格 eol(先归一为 \n 再输出,避免 CRLF 被二次转换)。
|
|
428
|
+
*/
|
|
429
|
+
function normalizeEol(text, eol) {
|
|
430
|
+
return text.replace(/\r\n?/g, "\n").replace(/\n/g, eol);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 换行符不敏感的替换兜底:文件为 CRLF 而 oldString 为 LF(或相反)时仍可命中。
|
|
435
|
+
* 原理:把原文与 oldString 的换行均归一为 \n 后匹配,并用索引映射表把命中位置
|
|
436
|
+
* 换算回原文位置,未命中区域逐字节保持原样;replacement 由调用方预先按文件
|
|
437
|
+
* 主导换行风格归一。返回 { updated, count },无命中返回 null。
|
|
438
|
+
*/
|
|
439
|
+
function eolInsensitiveReplace(original, oldString, replacement, replaceAll, ignoreCase) {
|
|
440
|
+
const parts = [];
|
|
441
|
+
const map = [];
|
|
442
|
+
for (let i = 0; i < original.length; i++) {
|
|
443
|
+
if (original.charCodeAt(i) === 13) {
|
|
444
|
+
// \r 与 \r\n 均折叠为一个 \n,并记录该换行在原文中的起点
|
|
445
|
+
parts.push("\n");
|
|
446
|
+
map.push(i);
|
|
447
|
+
if (original.charCodeAt(i + 1) === 10) i++;
|
|
448
|
+
} else {
|
|
449
|
+
parts.push(original[i]);
|
|
450
|
+
map.push(i);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
map.push(original.length);
|
|
454
|
+
const norm = parts.join("");
|
|
455
|
+
const normNeedle = oldString.replace(/\r\n?/g, "\n");
|
|
456
|
+
if (!normNeedle) return null;
|
|
457
|
+
const hay = ignoreCase ? norm.toLowerCase() : norm;
|
|
458
|
+
const needle = ignoreCase ? normNeedle.toLowerCase() : normNeedle;
|
|
459
|
+
const positions = [];
|
|
460
|
+
let idx = 0;
|
|
461
|
+
while ((idx = hay.indexOf(needle, idx)) !== -1) {
|
|
462
|
+
positions.push(idx);
|
|
463
|
+
idx += needle.length;
|
|
464
|
+
}
|
|
465
|
+
if (!positions.length) return null;
|
|
466
|
+
let updated = original;
|
|
467
|
+
// 从后往前替换,避免前面的替换使后面的原始索引失效
|
|
468
|
+
const targets = replaceAll ? positions : [positions[0]];
|
|
469
|
+
for (let i = targets.length - 1; i >= 0; i--) {
|
|
470
|
+
const p = targets[i];
|
|
471
|
+
const start = map[p];
|
|
472
|
+
const end = map[p + normNeedle.length];
|
|
473
|
+
updated = updated.slice(0, start) + replacement + updated.slice(end);
|
|
474
|
+
}
|
|
475
|
+
return { updated, count: positions.length };
|
|
476
|
+
}
|
|
477
|
+
|
|
171
478
|
// 注册 edit_file 工具(精确替换,替代受加密影响的 Edit/MultiEdit)
|
|
172
479
|
server.tool(
|
|
173
480
|
"edit_file",
|
|
174
|
-
"
|
|
481
|
+
"对文件内容做精确字符串或正则替换后写回(明文)。字符串匹配自动兼容 CRLF/LF 换行差异,替换文本行尾自动跟随文件主导风格。加密软件环境下内置 Edit/MultiEdit 直写会破坏加密,本工具用 Node.js fs 读改写,自动加密落盘。替代内置 Edit/MultiEdit 工具。",
|
|
175
482
|
{
|
|
176
483
|
path: z.string().describe("文件路径,支持相对路径或绝对路径"),
|
|
177
484
|
oldString: z.string().describe("要被替换的原字符串。useRegex=true 时作为正则表达式"),
|
|
@@ -182,59 +489,95 @@ server.tool(
|
|
|
182
489
|
},
|
|
183
490
|
async ({ path: filePath, oldString, newString, useRegex, replaceAll, ignoreCase }) => {
|
|
184
491
|
try {
|
|
185
|
-
|
|
492
|
+
// readFileContent 会剥离 BOM 并记录,写回时补回,避免 oldString 匹配首行失败
|
|
493
|
+
const readResult = readFileContent(filePath);
|
|
494
|
+
if (!readResult.ok) {
|
|
495
|
+
return { content: [{ type: "text", text: "❌ " + readResult.error }], isError: true };
|
|
496
|
+
}
|
|
497
|
+
const original = readResult.content;
|
|
498
|
+
const hasBom = readResult.hasBom;
|
|
499
|
+
// 非 UTF-8 防护:GBK 等编码按 UTF-8 读入会产生大量 U+FFFD,此时做任何替换再写回,
|
|
500
|
+
// 原始字节信息会永久丢失(不可逆损坏)。检测到即拒绝编辑并明确提示。
|
|
501
|
+
if (isLikelyNonUtf8(original)) {
|
|
502
|
+
return {
|
|
503
|
+
content: [{ type: "text", text: "❌ 文件疑似非 UTF-8 编码(GBK 等),按 UTF-8 读取出现大量乱码替换字符,继续编辑写回会不可逆损坏文件,已拒绝操作: " + filePath + "\n建议:先确认文件编码,转换为 UTF-8 后再编辑。" }],
|
|
504
|
+
isError: true,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
// 文件主导换行风格:CRLF 文件写入的替换文本也转成 CRLF,保持文件风格统一
|
|
508
|
+
const fileEol = detectEol(original);
|
|
509
|
+
const normalizedNew = normalizeEol(newString, fileEol);
|
|
186
510
|
let matcher;
|
|
187
511
|
if (useRegex) {
|
|
188
512
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
513
|
+
// 正则模式默认附加 m 标志:^/$ 按行锚定(Agent 常用行级正则习惯),JS 无内联标志无法由调用方自行开启
|
|
514
|
+
const flags = (replaceAll ? "g" : "") + "m" + (ignoreCase ? "i" : "");
|
|
515
|
+
matcher = new RegExp(oldString, flags);
|
|
191
516
|
} catch (e) {
|
|
192
517
|
return { content: [{ type: "text", text: "❌ 正则表达式无效: " + e.message }], isError: true };
|
|
193
518
|
}
|
|
194
519
|
}
|
|
195
520
|
let count;
|
|
196
521
|
let updated;
|
|
522
|
+
let eolAdapted = false; // 是否触发了换行符兼容替换
|
|
197
523
|
if (useRegex) {
|
|
198
|
-
|
|
524
|
+
// 计数与替换均带 m 标志,保持与构造 matcher 时一致
|
|
525
|
+
const globalMatcher = new RegExp(matcher.source, "gm" + (ignoreCase ? "i" : ""));
|
|
199
526
|
const matches = original.match(globalMatcher);
|
|
200
527
|
count = matches ? matches.length : 0;
|
|
201
|
-
const replaceMatcher = replaceAll ? globalMatcher : new RegExp(matcher.source, ignoreCase ? "i" : "");
|
|
202
|
-
updated = original.replace(replaceMatcher,
|
|
528
|
+
const replaceMatcher = replaceAll ? globalMatcher : new RegExp(matcher.source, "m" + (ignoreCase ? "i" : ""));
|
|
529
|
+
updated = original.replace(replaceMatcher, normalizedNew);
|
|
203
530
|
} else {
|
|
204
531
|
if (oldString === "") {
|
|
205
532
|
return { content: [{ type: "text", text: "❌ oldString 不能为空字符串" }], isError: true };
|
|
206
533
|
}
|
|
534
|
+
// 第一优先:原样精确匹配(字节级一致,最安全)
|
|
207
535
|
let idx = 0, c = 0;
|
|
208
536
|
const hay = ignoreCase ? original.toLowerCase() : original;
|
|
209
537
|
const needle = ignoreCase ? oldString.toLowerCase() : oldString;
|
|
210
538
|
while ((idx = hay.indexOf(needle, idx)) !== -1) { c++; idx += needle.length; }
|
|
211
539
|
count = c;
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
540
|
+
if (count > 0) {
|
|
541
|
+
if (replaceAll) {
|
|
542
|
+
const esc = oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
543
|
+
// 用函数替换避免 newString 中的 $& $1 等被误解析为替换模式
|
|
544
|
+
updated = original.replace(new RegExp(esc, ignoreCase ? "gi" : "g"), () => normalizedNew);
|
|
545
|
+
} else {
|
|
546
|
+
const pos = hay.indexOf(needle);
|
|
547
|
+
updated = original.slice(0, pos) + normalizedNew + original.slice(pos + oldString.length);
|
|
548
|
+
}
|
|
215
549
|
} else {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
550
|
+
// 第二优先:换行符兼容匹配。文件是 CRLF 而 oldString 用 LF(或相反)时,
|
|
551
|
+
// 字节级比对必然失败,这里归一换行后再匹配,命中即记为换行适配替换
|
|
552
|
+
const adapted = eolInsensitiveReplace(original, oldString, normalizedNew, replaceAll, ignoreCase);
|
|
553
|
+
if (adapted) {
|
|
554
|
+
count = adapted.count;
|
|
555
|
+
updated = adapted.updated;
|
|
556
|
+
eolAdapted = true;
|
|
219
557
|
} else {
|
|
220
|
-
|
|
558
|
+
count = 0;
|
|
559
|
+
updated = original;
|
|
221
560
|
}
|
|
222
561
|
}
|
|
223
562
|
}
|
|
224
563
|
if (count === 0) {
|
|
225
564
|
return {
|
|
226
|
-
content: [{ type: "text", text: "⚠️ 未找到匹配内容,文件未修改。请检查 oldString(或正则)是否正确: " + filePath }],
|
|
565
|
+
content: [{ type: "text", text: "⚠️ 未找到匹配内容,文件未修改。请检查 oldString(或正则)是否正确: " + filePath + "\n提示:若内容本身无误,请确认文件与 oldString 的换行风格(CRLF/LF)及空白字符是否一致。" }],
|
|
227
566
|
isError: true,
|
|
228
567
|
};
|
|
229
568
|
}
|
|
230
569
|
let warning = "";
|
|
570
|
+
if (eolAdapted) {
|
|
571
|
+
warning = "\nℹ️ 换行符已自动适配:oldString 与文件换行风格不一致(CRLF/LF),已按换行归一化匹配完成替换。";
|
|
572
|
+
}
|
|
231
573
|
if (!replaceAll && count > 1) {
|
|
232
|
-
warning
|
|
574
|
+
warning += "\n⚠️ 注意:共匹配 " + count + " 处,但 replaceAll=false 仅替换了第一处。如需全部替换请设 replaceAll=true。";
|
|
233
575
|
}
|
|
234
576
|
if (updated === original) {
|
|
235
577
|
return { content: [{ type: "text", text: "⚠️ 替换后内容无变化,文件未修改: " + filePath }] };
|
|
236
578
|
}
|
|
237
|
-
|
|
579
|
+
// 原文件带 BOM 时补回,保持文件编码特征不变(部分 Windows 软件依赖 BOM)
|
|
580
|
+
fs.writeFileSync(filePath, (hasBom ? "\uFEFF" : "") + updated, "utf-8");
|
|
238
581
|
return {
|
|
239
582
|
content: [{ type: "text", text: "✅ 替换成功: " + filePath + "\n替换 " + (replaceAll ? count : 1) + "/" + count + " 处" + warning }],
|
|
240
583
|
};
|
|
@@ -250,17 +593,18 @@ server.tool(
|
|
|
250
593
|
// 注册 search_files 工具(内容搜索,替代受加密影响的 Grep)
|
|
251
594
|
server.tool(
|
|
252
595
|
"search_files",
|
|
253
|
-
"
|
|
596
|
+
"在指定目录递归搜索文件内容(明文)。支持 include glob 过滤(*.java 或 **/*.js 均可,多个用逗号分隔);自动跳过二进制文件、超大文件(>5MB)、常见依赖/构建目录与隐藏文件(如 .env)。加密软件环境下内置 Grep(ripgrep) 只能读到密文搜不到内容,本工具用 Node.js fs 读取后正则匹配。替代内置 Grep 工具。注意:逐行匹配,不支持跨行正则。",
|
|
254
597
|
{
|
|
255
|
-
pattern: z.string().describe("正则表达式(如 log.*Error、function\\s+\\w
|
|
256
|
-
path: z.string().describe("
|
|
257
|
-
include: z.string().optional().describe("
|
|
598
|
+
pattern: z.string().describe("正则表达式(如 log.*Error、function\\s+\\w+),按行匹配,不支持跨行"),
|
|
599
|
+
path: z.string().describe("搜索根目录(或单个文件),支持相对路径或绝对路径"),
|
|
600
|
+
include: z.string().optional().describe("glob 过滤,多个用逗号分隔。支持文件名(*.java)与带目录通配的形式(src/**/*.js、**/*.test.ts)"),
|
|
601
|
+
exclude: z.string().optional().describe("额外排除的目录名,逗号分隔(默认已排除 node_modules/.git/target/build/dist 等)"),
|
|
258
602
|
ignoreCase: z.boolean().optional().describe("是否忽略大小写,默认 false"),
|
|
259
603
|
onlyMatching: z.boolean().optional().describe("是否只输出匹配部分(非整行),默认 false 输出整行"),
|
|
260
|
-
maxResults: z.number().optional().describe("
|
|
604
|
+
maxResults: z.number().int().positive().optional().describe("最大返回匹配数(正整数),默认 200。超过会在末尾提示被截断"),
|
|
261
605
|
},
|
|
262
606
|
{ readOnlyHint: true },
|
|
263
|
-
async ({ pattern, path: rootDir, include, ignoreCase, onlyMatching, maxResults }) => {
|
|
607
|
+
async ({ pattern, path: rootDir, include, exclude, ignoreCase, onlyMatching, maxResults }) => {
|
|
264
608
|
try {
|
|
265
609
|
// 修正:忽略大小写时需同时携带 g 与 i 标志,否则 ignoreCase 参数失效
|
|
266
610
|
const flags = ignoreCase ? "gi" : "g";
|
|
@@ -273,26 +617,48 @@ server.tool(
|
|
|
273
617
|
const includeList = include
|
|
274
618
|
? include.split(",").map((s) => s.trim()).filter(Boolean)
|
|
275
619
|
: null;
|
|
276
|
-
// include glob
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
620
|
+
// include glob 匹配:对「相对根目录的 posix 路径」做全路径匹配,
|
|
621
|
+
// 同时兼容 basename 命中,**/*.js 与 src/**/*.js 均可正确工作
|
|
622
|
+
const includeRegexes = includeList
|
|
623
|
+
? includeList.map((pat) => ({ pat, re: globToRegex(pat.replace(/\\/g, "/")) }))
|
|
624
|
+
: null;
|
|
625
|
+
const matchesInclude = (full) => {
|
|
626
|
+
if (!includeRegexes) return true;
|
|
627
|
+
const rel = path.relative(rootDir, full).replace(/\\/g, "/");
|
|
628
|
+
return includeRegexes.some(({ re }) => re.test(rel));
|
|
283
629
|
};
|
|
630
|
+
// 默认忽略目录:依赖/构建产物/IDE 缓存,可经 exclude 追加
|
|
631
|
+
const DEFAULT_IGNORE = ["node_modules", ".git", "target", "build", "dist", ".idea", ".vscode", ".svn", "bin", "obj", "out", "vendor"];
|
|
632
|
+
const excludeSet = new Set(DEFAULT_IGNORE);
|
|
633
|
+
if (exclude) {
|
|
634
|
+
for (const name of exclude.split(",").map((s) => s.trim()).filter(Boolean)) excludeSet.add(name);
|
|
635
|
+
}
|
|
284
636
|
const limit = maxResults || 200;
|
|
285
637
|
const results = [];
|
|
286
638
|
let truncated = false;
|
|
287
639
|
let scanned = 0;
|
|
640
|
+
let skippedBinary = 0;
|
|
641
|
+
let skippedLarge = 0;
|
|
288
642
|
let matchedFiles = 0;
|
|
289
643
|
// 对单个文件做内容匹配, 复用与目录遍历相同的行级逻辑
|
|
290
|
-
const scanFile = (full) => {
|
|
291
|
-
if (!matchesInclude(
|
|
644
|
+
const scanFile = (full, size) => {
|
|
645
|
+
if (!matchesInclude(full)) return;
|
|
292
646
|
scanned++;
|
|
647
|
+
// 超大文件直接跳过:minified 产物/大日志读入+正则可能卡死同步 server
|
|
648
|
+
if (size > SCAN_MAX_BYTES) { skippedLarge++; return; }
|
|
293
649
|
let content;
|
|
294
650
|
try {
|
|
295
|
-
|
|
651
|
+
// 真首块嗅探:只读前 8KB 判二进制,避免为嗅探而整读大文件
|
|
652
|
+
const sniff = readFilePrefix(full, BINARY_SNIFF_BYTES);
|
|
653
|
+
if (!sniff.ok) return;
|
|
654
|
+
if (isBinaryBuffer(sniff.firstBytes)) { skippedBinary++; return; }
|
|
655
|
+
if (sniff.isTruncated) {
|
|
656
|
+
// 文件大于嗅探预算:余量部分整体读取后拼接(5MB 上限保证内存可控)
|
|
657
|
+
content = sniff.text + fs.readFileSync(full, "utf-8").slice(sniff.text.length);
|
|
658
|
+
} else {
|
|
659
|
+
// 文件整体在预算内:首块即全文,避免第二次 IO
|
|
660
|
+
content = sniff.text;
|
|
661
|
+
}
|
|
296
662
|
} catch (e) {
|
|
297
663
|
return;
|
|
298
664
|
}
|
|
@@ -333,12 +699,16 @@ server.tool(
|
|
|
333
699
|
}
|
|
334
700
|
for (const entry of entries) {
|
|
335
701
|
if (truncated) return;
|
|
702
|
+
// 隐藏文件/目录默认跳过(.env/.gitignore 等可能含密钥,且多为配置噪音)
|
|
703
|
+
if (entry.name.startsWith(".")) continue;
|
|
336
704
|
const full = path.join(dir, entry.name);
|
|
337
705
|
if (entry.isDirectory()) {
|
|
338
|
-
if (
|
|
706
|
+
if (excludeSet.has(entry.name)) continue;
|
|
339
707
|
walk(full);
|
|
340
708
|
} else if (entry.isFile()) {
|
|
341
|
-
|
|
709
|
+
let size = 0;
|
|
710
|
+
try { size = fs.statSync(full).size; } catch (e) { /* stat 失败按 0 处理,交给 readFileSync 报错 */ }
|
|
711
|
+
scanFile(full, size);
|
|
342
712
|
}
|
|
343
713
|
}
|
|
344
714
|
};
|
|
@@ -355,16 +725,22 @@ server.tool(
|
|
|
355
725
|
return { content: [{ type: "text", text: "❌ 无法访问路径: " + e.message }], isError: true };
|
|
356
726
|
}
|
|
357
727
|
if (stat.isFile()) {
|
|
358
|
-
scanFile(rootDir);
|
|
728
|
+
scanFile(rootDir, stat.size);
|
|
359
729
|
} else if (stat.isDirectory()) {
|
|
360
730
|
walk(rootDir);
|
|
361
731
|
}
|
|
362
732
|
let text = results.join("\n");
|
|
363
733
|
if (results.length === 0) {
|
|
364
|
-
|
|
734
|
+
let parts = ["未找到匹配项(扫描 " + scanned + " 个文件,根目录: " + rootDir + ")"];
|
|
735
|
+
if (skippedBinary) parts.push("跳过二进制文件 " + skippedBinary + " 个");
|
|
736
|
+
if (skippedLarge) parts.push("跳过超大文件(" + (SCAN_MAX_BYTES / 1024 / 1024) + "MB+) " + skippedLarge + " 个");
|
|
737
|
+
text = parts.join(",");
|
|
365
738
|
} else {
|
|
366
739
|
text = "找到 " + results.length + " 处匹配(" + matchedFiles + " 个文件,扫描 " + scanned + " 个文件):\n" + text;
|
|
367
740
|
if (truncated) text += "\n... 结果已达上限 " + limit + ",被截断。可通过 maxResults 调大。";
|
|
741
|
+
if (skippedBinary || skippedLarge) {
|
|
742
|
+
text += "\nℹ️ 已跳过: 二进制文件 " + skippedBinary + " 个,超大文件 " + skippedLarge + " 个。";
|
|
743
|
+
}
|
|
368
744
|
}
|
|
369
745
|
return { content: [{ type: "text", text }] };
|
|
370
746
|
} catch (e) {
|
|
@@ -391,17 +767,35 @@ server.tool(
|
|
|
391
767
|
// 注册 file_info 工具(查询文件/目录信息)
|
|
392
768
|
server.tool(
|
|
393
769
|
"file_info",
|
|
394
|
-
"
|
|
770
|
+
"查询文件或目录的信息:是否存在、类型、大小、修改时间、符号链接等。注意:加密环境下 stat.size 反映的是密文字节数(与明文不一致),文件场景请以 sizePlaintext(明文字节数)为准。",
|
|
395
771
|
{ path: z.string().describe("文件或目录路径,支持相对路径或绝对路径") },
|
|
396
772
|
{ readOnlyHint: true },
|
|
397
773
|
async ({ path: filePath }) => {
|
|
398
774
|
try {
|
|
399
|
-
|
|
775
|
+
// 用 lstat 不跟随符号链接:坏链接可区分「链接存在但目标丢失」与「真不存在」
|
|
776
|
+
const lstat = fs.lstatSync(filePath);
|
|
777
|
+
const isSymlink = lstat.isSymbolicLink();
|
|
778
|
+
let stat = lstat;
|
|
779
|
+
let targetInfo = null;
|
|
780
|
+
if (isSymlink) {
|
|
781
|
+
try {
|
|
782
|
+
stat = fs.statSync(filePath); // 跟随链接取真实目标信息
|
|
783
|
+
targetInfo = stat.isDirectory() ? "directory" : "file";
|
|
784
|
+
} catch (e) {
|
|
785
|
+
targetInfo = "broken(目标不存在)";
|
|
786
|
+
}
|
|
787
|
+
}
|
|
400
788
|
const info = {
|
|
401
789
|
path: filePath,
|
|
402
790
|
exists: true,
|
|
403
|
-
type:
|
|
404
|
-
|
|
791
|
+
type: isSymlink ? "symlink" : lstat.isDirectory() ? "directory" : "file",
|
|
792
|
+
...(isSymlink ? { symlinkTarget: fs.readlinkSync(filePath), targetType: targetInfo } : {}),
|
|
793
|
+
// 密文字节数:加密环境下的磁盘占用,与明文大小不一致
|
|
794
|
+
sizeOnDisk: stat.size,
|
|
795
|
+
// 明文字节数:仅普通文件场景提供;读取失败(非 UTF-16 拒绝/权限等)置 null 而非误导性的 0
|
|
796
|
+
sizePlaintext: !isSymlink && !lstat.isDirectory()
|
|
797
|
+
? (() => { const r = readFileContent(filePath); return r.ok ? r.size : null; })()
|
|
798
|
+
: null,
|
|
405
799
|
modifiedTime: stat.mtime.toISOString(),
|
|
406
800
|
createdTime: stat.birthtime.toISOString(),
|
|
407
801
|
};
|
|
@@ -418,11 +812,280 @@ server.tool(
|
|
|
418
812
|
// 注册 check_status 工具
|
|
419
813
|
server.tool(
|
|
420
814
|
"check_status",
|
|
421
|
-
"
|
|
422
|
-
{
|
|
815
|
+
"检查文件操作工具的运行状态。可选提供 path 参数做实测:真实读取该文件验证 Node.js 白名单解密能力(读到明文返回成功;不传则只做基础心跳检查,不验证解密)。",
|
|
816
|
+
{
|
|
817
|
+
path: z.string().optional().describe("可选。提供时实际读取该文件验证明文可读性(建议传一个已知的加密文件)"),
|
|
818
|
+
},
|
|
423
819
|
{ readOnlyHint: true },
|
|
424
|
-
async () => {
|
|
425
|
-
|
|
820
|
+
async ({ path: filePath }) => {
|
|
821
|
+
let base = "✅ read-file-server 运行中\n平台: Node.js " + process.version + "\n版本: " + pkg.version + "\n功能: 通过 Node.js fs 读写文件明文(加密软件白名单中的 Node.js 进程自动解密/加密)";
|
|
822
|
+
if (filePath === undefined) {
|
|
823
|
+
base += "\n提示: 传入 path 参数可实测解密能力(本次未做实测)";
|
|
824
|
+
return { content: [{ type: "text", text: base }] };
|
|
825
|
+
}
|
|
826
|
+
// 实测模式:真实读一次文件,验证白名单解密链路
|
|
827
|
+
const result = readFileContent(filePath, 200);
|
|
828
|
+
if (result.ok) {
|
|
829
|
+
return {
|
|
830
|
+
content: [{
|
|
831
|
+
type: "text",
|
|
832
|
+
text: base + "\n\n实测: 已成功读取 " + filePath + "(前 " + Math.min(result.content.length, 200) + " 字符,明文大小 " + result.size + " 字节)\n结论: Node.js 解密能力正常。",
|
|
833
|
+
}],
|
|
834
|
+
};
|
|
835
|
+
} else {
|
|
836
|
+
return {
|
|
837
|
+
content: [{ type: "text", text: base + "\n\n实测: 读取 " + filePath + " 失败 -- " + result.error + "\n结论: Node.js 可能不在加密软件白名单,请联系管理员将 node.exe 加入白名单。" }],
|
|
838
|
+
isError: true,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
// 注册 list_directory 工具(列目录,替代内置 LS)
|
|
845
|
+
server.tool(
|
|
846
|
+
"list_directory",
|
|
847
|
+
"列出指定目录的内容(文件与子目录清单),加密环境下替代内置 LS / bash ls。每项含名称、类型(file/directory/symlink)、大小与修改时间;默认不显示隐藏项。",
|
|
848
|
+
{
|
|
849
|
+
path: z.string().describe("目录路径,支持相对路径或绝对路径"),
|
|
850
|
+
showHidden: z.boolean().optional().describe("是否包含以 . 开头的隐藏项,默认 false"),
|
|
851
|
+
},
|
|
852
|
+
{ readOnlyHint: true },
|
|
853
|
+
async ({ path: dirPath, showHidden }) => {
|
|
854
|
+
try {
|
|
855
|
+
const stat = fs.statSync(dirPath);
|
|
856
|
+
if (!stat.isDirectory()) {
|
|
857
|
+
return { content: [{ type: "text", text: "❌ 路径不是目录: " + dirPath }], isError: true };
|
|
858
|
+
}
|
|
859
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
860
|
+
const lines = [];
|
|
861
|
+
for (const entry of entries) {
|
|
862
|
+
if (!showHidden && entry.name.startsWith(".")) continue;
|
|
863
|
+
const full = path.join(dirPath, entry.name);
|
|
864
|
+
let type = "file";
|
|
865
|
+
let size = "";
|
|
866
|
+
let mtime = "";
|
|
867
|
+
try {
|
|
868
|
+
const st = fs.lstatSync(full);
|
|
869
|
+
if (st.isSymbolicLink()) type = "symlink";
|
|
870
|
+
else if (st.isDirectory()) type = "directory";
|
|
871
|
+
// 注意:size 为密文字节数(加密环境),仅供参考
|
|
872
|
+
size = st.isDirectory() ? "-" : String(st.size);
|
|
873
|
+
mtime = st.mtime.toISOString().replace("T", " ").slice(0, 19);
|
|
874
|
+
} catch (e) { /* stat 失败时保留默认值 */ }
|
|
875
|
+
lines.push(String(type === "directory" ? "DIR " : "FILE").padEnd(5) + " " + size.padStart(10) + " " + mtime + " " + entry.name);
|
|
876
|
+
}
|
|
877
|
+
const header = "目录: " + dirPath + "(共 " + lines.length + " 项" + (showHidden ? "" : ",不含隐藏项") + ")";
|
|
878
|
+
return { content: [{ type: "text", text: lines.length ? header + "\n" + lines.join("\n") : header + "\n(空目录或全部被隐藏项过滤)" }] };
|
|
879
|
+
} catch (e) {
|
|
880
|
+
if (e.code === "ENOENT") {
|
|
881
|
+
return { content: [{ type: "text", text: "❌ 目录不存在: " + dirPath }], isError: true };
|
|
882
|
+
}
|
|
883
|
+
return { content: [{ type: "text", text: "❌ 列目录失败: " + e.message }], isError: true };
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
// 注册 find_files 工具(按文件名 glob 查找,替代内置 Glob)
|
|
889
|
+
server.tool(
|
|
890
|
+
"find_files",
|
|
891
|
+
"按文件名 glob 模式递归查找文件/目录(如 *.test.js、**/*.java、src/**/*.ts),加密环境下替代内置 Glob / bash find。默认跳过 node_modules、.git 等依赖与构建目录。",
|
|
892
|
+
{
|
|
893
|
+
pattern: z.string().describe("glob 模式,如 *.java、**/*.test.js、src/**/*.ts。* 不跨目录,** 跨目录"),
|
|
894
|
+
path: z.string().describe("搜索根目录,支持相对路径或绝对路径"),
|
|
895
|
+
maxResults: z.number().int().positive().optional().describe("最大返回条数(正整数),默认 500"),
|
|
896
|
+
},
|
|
897
|
+
{ readOnlyHint: true },
|
|
898
|
+
async ({ pattern, path: rootDir, maxResults }) => {
|
|
899
|
+
try {
|
|
900
|
+
const limit = maxResults || 500;
|
|
901
|
+
const regex = globToRegex(pattern.replace(/\\/g, "/"));
|
|
902
|
+
const results = [];
|
|
903
|
+
let truncated = false;
|
|
904
|
+
// 与 search_files 一致的默认忽略列表,另含隐藏目录
|
|
905
|
+
const IGNORE = ["node_modules", ".git", "target", "build", "dist", ".idea", ".vscode", ".svn", "bin", "obj", "out", "vendor"];
|
|
906
|
+
const walk = (dir) => {
|
|
907
|
+
if (truncated) return;
|
|
908
|
+
let entries;
|
|
909
|
+
try {
|
|
910
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
911
|
+
} catch (e) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
for (const entry of entries) {
|
|
915
|
+
if (truncated) return;
|
|
916
|
+
if (entry.name.startsWith(".")) continue;
|
|
917
|
+
const full = path.join(dir, entry.name);
|
|
918
|
+
const rel = path.relative(rootDir, full).replace(/\\/g, "/");
|
|
919
|
+
if (regex.test(rel)) {
|
|
920
|
+
results.push((entry.isDirectory() ? "DIR " : "FILE ") + full);
|
|
921
|
+
if (results.length >= limit) { truncated = true; return; }
|
|
922
|
+
}
|
|
923
|
+
if (entry.isDirectory()) {
|
|
924
|
+
if (!IGNORE.includes(entry.name)) walk(full);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
let stat;
|
|
929
|
+
try {
|
|
930
|
+
stat = fs.statSync(rootDir);
|
|
931
|
+
} catch (e) {
|
|
932
|
+
if (e.code === "ENOENT") {
|
|
933
|
+
return { content: [{ type: "text", text: "❌ 路径不存在: " + rootDir }], isError: true };
|
|
934
|
+
}
|
|
935
|
+
return { content: [{ type: "text", text: "❌ 无法访问路径: " + e.message }], isError: true };
|
|
936
|
+
}
|
|
937
|
+
// 根路径本身也参与匹配(如 pattern 恰好等于根目录名)
|
|
938
|
+
if (!stat.isDirectory()) {
|
|
939
|
+
return { content: [{ type: "text", text: "❌ 路径不是目录: " + rootDir }], isError: true };
|
|
940
|
+
}
|
|
941
|
+
walk(rootDir);
|
|
942
|
+
let text;
|
|
943
|
+
if (results.length === 0) {
|
|
944
|
+
text = "未找到匹配 " + pattern + " 的文件(根目录: " + rootDir + ")";
|
|
945
|
+
} else {
|
|
946
|
+
text = "找到 " + results.length + " 个匹配(根目录: " + rootDir + "):\n" + results.join("\n");
|
|
947
|
+
if (truncated) text += "\n... 结果已达上限 " + limit + ",被截断。可通过 maxResults 调大。";
|
|
948
|
+
}
|
|
949
|
+
return { content: [{ type: "text", text }] };
|
|
950
|
+
} catch (e) {
|
|
951
|
+
return { content: [{ type: "text", text: "❌ 查找失败: " + e.message }], isError: true };
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
// 注册 copy_path 工具(复制文件/目录,替代 bash cp)
|
|
957
|
+
server.tool(
|
|
958
|
+
"copy_path",
|
|
959
|
+
"复制文件或目录(目录递归复制)。加密环境下必须经 Node.js 白名单进程复制(bash cp 产出密文/双重加密文件,在白名单视图下即损坏)。目标已存在时:文件被覆盖,目录合并。",
|
|
960
|
+
{
|
|
961
|
+
source: z.string().describe("源路径(文件或目录)"),
|
|
962
|
+
destination: z.string().describe("目标路径。目标已存在时文件覆盖、目录合并;不存在时自动创建"),
|
|
963
|
+
},
|
|
964
|
+
async ({ source, destination }) => {
|
|
965
|
+
try {
|
|
966
|
+
// 单次 stat 消除 existsSync+statSync 双调用的竞态窗口
|
|
967
|
+
let srcStat;
|
|
968
|
+
try {
|
|
969
|
+
srcStat = fs.statSync(source);
|
|
970
|
+
} catch (e) {
|
|
971
|
+
if (e.code === "ENOENT") {
|
|
972
|
+
return { content: [{ type: "text", text: "❌ 源路径不存在: " + source }], isError: true };
|
|
973
|
+
}
|
|
974
|
+
throw e;
|
|
975
|
+
}
|
|
976
|
+
// 目标为已存在目录时,将源合并/放入目标目录下(与 bash cp 的自然预期一致)
|
|
977
|
+
let finalDest = destination;
|
|
978
|
+
let destDirStat = null;
|
|
979
|
+
try {
|
|
980
|
+
destDirStat = fs.statSync(destination);
|
|
981
|
+
} catch (e) {
|
|
982
|
+
if (e.code !== "ENOENT") throw e;
|
|
983
|
+
}
|
|
984
|
+
if (destDirStat && destDirStat.isDirectory() && path.basename(source)) {
|
|
985
|
+
finalDest = path.join(destination, path.basename(source));
|
|
986
|
+
}
|
|
987
|
+
let destStat = null;
|
|
988
|
+
try {
|
|
989
|
+
destStat = fs.statSync(finalDest);
|
|
990
|
+
} catch (e) {
|
|
991
|
+
if (e.code !== "ENOENT") throw e;
|
|
992
|
+
}
|
|
993
|
+
// 类型冲突检查:文件 -> 已有目录 或 目录 -> 已有文件,均直接报错避免误操作
|
|
994
|
+
if (srcStat.isFile() && destStat && destStat.isDirectory()) {
|
|
995
|
+
return { content: [{ type: "text", text: "❌ 无法复制:源是文件但目标是已存在的目录: " + finalDest }], isError: true };
|
|
996
|
+
}
|
|
997
|
+
if (srcStat.isDirectory() && destStat && destStat.isFile()) {
|
|
998
|
+
return { content: [{ type: "text", text: "❌ 无法复制:源是目录但目标是已存在的文件: " + finalDest }], isError: true };
|
|
999
|
+
}
|
|
1000
|
+
fs.cpSync(source, finalDest, { recursive: srcStat.isDirectory(), force: true });
|
|
1001
|
+
return { content: [{ type: "text", text: "✅ 复制成功: " + source + " -> " + finalDest + (srcStat.isDirectory() ? "(递归目录)" : "") }] };
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
return { content: [{ type: "text", text: "❌ 复制失败: " + e.message }], isError: true };
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
// 注册 move_path 工具(移动/重命名,替代 bash mv)
|
|
1009
|
+
server.tool(
|
|
1010
|
+
"move_path",
|
|
1011
|
+
"移动或重命名文件/目录。同盘符用 rename(原子操作),跨盘符自动回退为复制后删除源。加密环境下替代 bash mv。",
|
|
1012
|
+
{
|
|
1013
|
+
source: z.string().describe("源路径(文件或目录)"),
|
|
1014
|
+
destination: z.string().describe("目标路径。目标已存在的目录则移入其下;目标已存在的文件则覆盖"),
|
|
1015
|
+
},
|
|
1016
|
+
async ({ source, destination }) => {
|
|
1017
|
+
try {
|
|
1018
|
+
// 单次 stat 消除双调用竞态
|
|
1019
|
+
let srcStat;
|
|
1020
|
+
try {
|
|
1021
|
+
srcStat = fs.statSync(source);
|
|
1022
|
+
} catch (e) {
|
|
1023
|
+
if (e.code === "ENOENT") {
|
|
1024
|
+
return { content: [{ type: "text", text: "❌ 源路径不存在: " + source }], isError: true };
|
|
1025
|
+
}
|
|
1026
|
+
throw e;
|
|
1027
|
+
}
|
|
1028
|
+
let finalDest = destination;
|
|
1029
|
+
// 目标为已存在目录时移入其下(与 bash mv 预期一致)
|
|
1030
|
+
try {
|
|
1031
|
+
const st = fs.statSync(destination);
|
|
1032
|
+
if (st.isDirectory()) finalDest = path.join(destination, path.basename(source));
|
|
1033
|
+
} catch (e) {
|
|
1034
|
+
if (e.code !== "ENOENT") throw e;
|
|
1035
|
+
}
|
|
1036
|
+
try {
|
|
1037
|
+
fs.renameSync(source, finalDest);
|
|
1038
|
+
return { content: [{ type: "text", text: "✅ 移动成功: " + source + " -> " + finalDest }] };
|
|
1039
|
+
} catch (e) {
|
|
1040
|
+
if (e.code === "EXDEV") {
|
|
1041
|
+
// 跨盘符:rename 不可用,回退为 cp + rm
|
|
1042
|
+
fs.cpSync(source, finalDest, { recursive: srcStat.isDirectory(), force: true });
|
|
1043
|
+
fs.rmSync(source, { recursive: srcStat.isDirectory(), force: true });
|
|
1044
|
+
return { content: [{ type: "text", text: "✅ 移动成功(跨盘符,复制后删除源): " + source + " -> " + finalDest }] };
|
|
1045
|
+
}
|
|
1046
|
+
throw e;
|
|
1047
|
+
}
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
if (e.code === "ENOENT") {
|
|
1050
|
+
return { content: [{ type: "text", text: "❌ 源路径不存在: " + source }], isError: true };
|
|
1051
|
+
}
|
|
1052
|
+
return { content: [{ type: "text", text: "❌ 移动失败: " + e.message }], isError: true };
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
);
|
|
1056
|
+
|
|
1057
|
+
// 注册 remove_path 工具(删除文件/目录,替代 bash rm)
|
|
1058
|
+
server.tool(
|
|
1059
|
+
"remove_path",
|
|
1060
|
+
"删除文件或目录(目录默认递归删除,不可恢复,请谨慎使用)。加密环境下替代 bash rm。可选 recursive=false 时目录必须为空才可删除。",
|
|
1061
|
+
{
|
|
1062
|
+
path: z.string().describe("要删除的文件或目录路径"),
|
|
1063
|
+
recursive: z.boolean().optional().describe("目录是否递归删除,默认 true。false 时目录非空会报错"),
|
|
1064
|
+
},
|
|
1065
|
+
async ({ path: targetPath, recursive }) => {
|
|
1066
|
+
try {
|
|
1067
|
+
const stat = fs.statSync(targetPath);
|
|
1068
|
+
if (stat.isDirectory()) {
|
|
1069
|
+
// 递归删除前统计内容数量,写入结果让调用方有迹可查
|
|
1070
|
+
let count = 0;
|
|
1071
|
+
try {
|
|
1072
|
+
count = fs.readdirSync(targetPath).length;
|
|
1073
|
+
} catch (e) { /* 统计失败不影响删除 */ }
|
|
1074
|
+
fs.rmSync(targetPath, { recursive: recursive !== false, force: false });
|
|
1075
|
+
return { content: [{ type: "text", text: "✅ 已删除目录" + (recursive !== false && count ? "(含 " + count + " 项内容)" : "") + ": " + targetPath }] };
|
|
1076
|
+
}
|
|
1077
|
+
fs.rmSync(targetPath, { force: false });
|
|
1078
|
+
return { content: [{ type: "text", text: "✅ 已删除文件: " + targetPath }] };
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
if (e.code === "ENOENT") {
|
|
1081
|
+
return { content: [{ type: "text", text: "❌ 路径不存在: " + targetPath }], isError: true };
|
|
1082
|
+
}
|
|
1083
|
+
if (e.code === "ENOTEMPTY" || e.code === "EISDIR" || e.code === "ERR_FS_EISDIR") {
|
|
1084
|
+
// rmSync 对非空目录且 recursive=false 在 Linux 抛 ENOTEMPTY,Windows 新版 Node 抛 ERR_FS_EISDIR
|
|
1085
|
+
return { content: [{ type: "text", text: "❌ 目录非空,需 recursive=true(默认)才能递归删除: " + targetPath }], isError: true };
|
|
1086
|
+
}
|
|
1087
|
+
return { content: [{ type: "text", text: "❌ 删除失败: " + e.message }], isError: true };
|
|
1088
|
+
}
|
|
426
1089
|
}
|
|
427
1090
|
);
|
|
428
1091
|
|
package/package.json
CHANGED