mcp-read-file-server 1.3.1 → 1.6.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 +37 -33
- package/SKILL.md +35 -12
- package/index.js +860 -107
- package/package.json +1 -1
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
|
+
}
|
|
46
131
|
return { ok: false, error: "读取失败(可能是密文,请确认 Node.js 是否被加密软件列为白名单进程): " + e.message };
|
|
47
132
|
}
|
|
48
133
|
}
|
|
49
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 };
|
|
227
|
+
return { ok: false, error: "读取失败(可能是密文,请确认 Node.js 是否被加密软件列为白名单进程): " + e.message };
|
|
228
|
+
} finally {
|
|
229
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) { /* 忽略关闭失败 */ } }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
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,93 +374,300 @@ 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
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 匹配失败时的相似行诊断:在文件中找出与 oldString 首行(或整体)最相似的行,
|
|
480
|
+
* 输出行号与原文,帮助 Agent 快速定位「看起来一样实际差个空白/字符」的位置。
|
|
481
|
+
* 诊断方式:按公共 8-gram 计数相似度(无依赖的轻量实现),返回最多 3 条候选。
|
|
482
|
+
*/
|
|
483
|
+
function diagnoseNoMatch(original, oldString) {
|
|
484
|
+
// 取 oldString 的首行做行级比对(多数失败源于首行定位错误)
|
|
485
|
+
const firstLine = oldString.split(/\r?\n/)[0].trim();
|
|
486
|
+
if (!firstLine || firstLine.length < 6) return "";
|
|
487
|
+
const grams = new Set();
|
|
488
|
+
const N = 8;
|
|
489
|
+
for (let i = 0; i + N <= firstLine.length; i++) grams.add(firstLine.slice(i, i + N));
|
|
490
|
+
if (!grams.size) return "";
|
|
491
|
+
const lines = original.split(/\r?\n/);
|
|
492
|
+
let best = [];
|
|
493
|
+
for (let i = 0; i < lines.length; i++) {
|
|
494
|
+
const line = lines[i];
|
|
495
|
+
if (!line.trim()) continue;
|
|
496
|
+
let hit = 0;
|
|
497
|
+
for (let j = 0; j + N <= line.length; j++) {
|
|
498
|
+
if (grams.has(line.slice(j, j + N))) hit++;
|
|
499
|
+
}
|
|
500
|
+
const denom = Math.max(grams.size, Math.ceil(line.trim().length / N));
|
|
501
|
+
const sim = hit / denom;
|
|
502
|
+
if (sim > 0.25) best.push({ line: i + 1, sim, text: line.trim().slice(0, 120) });
|
|
503
|
+
}
|
|
504
|
+
best.sort((a, b) => b.sim - a.sim);
|
|
505
|
+
best = best.slice(0, 3);
|
|
506
|
+
if (!best.length) return "";
|
|
507
|
+
return "\n可能相关的行(相似度排序,请对照检查空白/缩进/字符差异):\n" +
|
|
508
|
+
best.map((b) => " 第 " + b.line + " 行 (相似度 " + Math.round(b.sim * 100) + "%): " + b.text).join("\n");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 对文本应用一次替换(edit_file 单次模式与批量模式的每个条目共用)。
|
|
513
|
+
* 两级匹配:①字节级精确匹配 ②换行归一化兜底(CRLF/LF 差异)。
|
|
514
|
+
* 返回 { ok, updated, count, eolAdapted, error, diagnose };失败时 error 带原因,
|
|
515
|
+
* diagnose 带相似行诊断(仅字符串模式且完全无命中时提供)。
|
|
516
|
+
*/
|
|
517
|
+
function applySingleEdit(original, oldString, newString, useRegex, replaceAll, ignoreCase) {
|
|
518
|
+
// 替换文本统一按文件主导行尾风格写入(调用方已归一 newString)
|
|
519
|
+
let matcher = null;
|
|
520
|
+
if (useRegex) {
|
|
521
|
+
try {
|
|
522
|
+
// 正则模式默认附加 m 标志:^/$ 按行锚定(Agent 常用行级正则习惯),JS 无内联标志无法由调用方自行开启
|
|
523
|
+
matcher = new RegExp(oldString, ((replaceAll ? "g" : "") + "m" + (ignoreCase ? "i" : "")));
|
|
524
|
+
} catch (e) {
|
|
525
|
+
return { ok: false, error: "正则表达式无效: " + e.message };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (useRegex) {
|
|
529
|
+
// 计数与替换均带 m 标志,保持与构造 matcher 时一致
|
|
530
|
+
const globalMatcher = new RegExp(matcher.source, "gm" + (ignoreCase ? "i" : ""));
|
|
531
|
+
const matches = original.match(globalMatcher);
|
|
532
|
+
const count = matches ? matches.length : 0;
|
|
533
|
+
if (count === 0) return { ok: false, error: "正则未匹配到内容", count: 0 };
|
|
534
|
+
const replaceMatcher = replaceAll ? globalMatcher : new RegExp(matcher.source, "m" + (ignoreCase ? "i" : ""));
|
|
535
|
+
return { ok: true, updated: original.replace(replaceMatcher, newString), count, eolAdapted: false };
|
|
536
|
+
}
|
|
537
|
+
if (oldString === "") {
|
|
538
|
+
return { ok: false, error: "oldString 不能为空字符串" };
|
|
539
|
+
}
|
|
540
|
+
// 第一优先:原样精确匹配(字节级一致,最安全)
|
|
541
|
+
const hay = ignoreCase ? original.toLowerCase() : original;
|
|
542
|
+
const needle = ignoreCase ? oldString.toLowerCase() : oldString;
|
|
543
|
+
let idx = 0, exactCount = 0;
|
|
544
|
+
while ((idx = hay.indexOf(needle, idx)) !== -1) { exactCount++; idx += needle.length; }
|
|
545
|
+
if (exactCount > 0) {
|
|
546
|
+
let updated;
|
|
547
|
+
if (replaceAll) {
|
|
548
|
+
const esc = oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
549
|
+
// 用函数替换避免 newString 中的 $& $1 等被误解析为替换模式
|
|
550
|
+
updated = original.replace(new RegExp(esc, ignoreCase ? "gi" : "g"), () => newString);
|
|
551
|
+
} else {
|
|
552
|
+
const pos = hay.indexOf(needle);
|
|
553
|
+
updated = original.slice(0, pos) + newString + original.slice(pos + oldString.length);
|
|
554
|
+
}
|
|
555
|
+
return { ok: true, updated, count: exactCount, eolAdapted: false };
|
|
556
|
+
}
|
|
557
|
+
// 第二优先:换行符兼容匹配。文件是 CRLF 而 oldString 用 LF(或相反)时,
|
|
558
|
+
// 字节级比对必然失败,归一换行后再匹配,命中即记为换行适配替换
|
|
559
|
+
const adapted = eolInsensitiveReplace(original, oldString, newString, replaceAll, ignoreCase);
|
|
560
|
+
if (adapted) {
|
|
561
|
+
return { ok: true, updated: adapted.updated, count: adapted.count, eolAdapted: true };
|
|
562
|
+
}
|
|
563
|
+
return { ok: false, error: "未找到匹配内容", count: 0, diagnose: diagnoseNoMatch(original, oldString) };
|
|
564
|
+
}
|
|
565
|
+
|
|
171
566
|
// 注册 edit_file 工具(精确替换,替代受加密影响的 Edit/MultiEdit)
|
|
172
567
|
server.tool(
|
|
173
568
|
"edit_file",
|
|
174
|
-
"
|
|
569
|
+
"对文件内容做精确字符串或正则替换后写回(明文)。支持两种形态:①单次替换(oldString/newString);②批量原子编辑(edits 数组,按序应用,任一条目失败则整体不写盘,避免半改状态)。字符串匹配自动兼容 CRLF/LF 换行差异,替换文本行尾自动跟随文件主导风格。加密软件环境下内置 Edit/MultiEdit 直写会破坏加密,本工具用 Node.js fs 读改写,自动加密落盘。替代内置 Edit/MultiEdit 工具。",
|
|
175
570
|
{
|
|
176
571
|
path: z.string().describe("文件路径,支持相对路径或绝对路径"),
|
|
177
|
-
oldString: z.string().describe("
|
|
178
|
-
newString: z.string().describe("
|
|
179
|
-
|
|
180
|
-
|
|
572
|
+
oldString: z.string().optional().describe("单次模式必填:要被替换的原字符串。useRegex=true 时作为正则表达式"),
|
|
573
|
+
newString: z.string().optional().describe("单次模式必填:替换后的字符串。正则模式下可用 $1 $2 等捕获组引用"),
|
|
574
|
+
edits: z.array(z.object({
|
|
575
|
+
oldString: z.string().describe("要被替换的原字符串(批量条目,不支持正则)"),
|
|
576
|
+
newString: z.string().describe("替换后的字符串(批量条目)"),
|
|
577
|
+
replaceAll: z.boolean().optional().describe("是否替换全部匹配项,默认 false 仅替换第一处"),
|
|
578
|
+
})).optional().describe("批量原子编辑模式:多个替换按序应用,任一条目未匹配则整体不写盘。适合一次完成多处修改(替代 MultiEdit)"),
|
|
579
|
+
useRegex: z.boolean().optional().describe("单次模式:是否将 oldString 当作正则表达式,默认 false(纯字符串匹配)"),
|
|
580
|
+
replaceAll: z.boolean().optional().describe("单次模式:是否替换全部匹配项,默认 false 仅替换第一处"),
|
|
181
581
|
ignoreCase: z.boolean().optional().describe("是否忽略大小写,默认 false。仅在非正则的字符串模式下生效"),
|
|
182
582
|
},
|
|
183
|
-
async ({ path: filePath, oldString, newString, useRegex, replaceAll, ignoreCase }) => {
|
|
583
|
+
async ({ path: filePath, oldString, newString, edits, useRegex, replaceAll, ignoreCase }) => {
|
|
184
584
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
const flags = replaceAll ? "g" : "";
|
|
190
|
-
matcher = new RegExp(oldString, ignoreCase ? flags + "i" : flags);
|
|
191
|
-
} catch (e) {
|
|
192
|
-
return { content: [{ type: "text", text: "❌ 正则表达式无效: " + e.message }], isError: true };
|
|
193
|
-
}
|
|
585
|
+
// readFileContent 会剥离 BOM 并记录,写回时补回,避免 oldString 匹配首行失败
|
|
586
|
+
const readResult = readFileContent(filePath);
|
|
587
|
+
if (!readResult.ok) {
|
|
588
|
+
return { content: [{ type: "text", text: "❌ " + readResult.error }], isError: true };
|
|
194
589
|
}
|
|
195
|
-
let
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
590
|
+
let original = readResult.content;
|
|
591
|
+
const hasBom = readResult.hasBom;
|
|
592
|
+
// 非 UTF-8 防护:GBK 等编码按 UTF-8 读入会产生大量 U+FFFD,此时做任何替换再写回,
|
|
593
|
+
// 原始字节信息会永久丢失(不可逆损坏)。检测到即拒绝编辑并明确提示。
|
|
594
|
+
if (isLikelyNonUtf8(original)) {
|
|
595
|
+
return {
|
|
596
|
+
content: [{ type: "text", text: "❌ 文件疑似非 UTF-8 编码(GBK 等),按 UTF-8 读取出现大量乱码替换字符,继续编辑写回会不可逆损坏文件,已拒绝操作: " + filePath + "\n建议:先确认文件编码,转换为 UTF-8 后再编辑。" }],
|
|
597
|
+
isError: true,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// 形态分派:edits 数组走批量原子模式;否则要求 oldString/newString 成对
|
|
601
|
+
const isBatch = Array.isArray(edits) && edits.length > 0;
|
|
602
|
+
if (!isBatch && (oldString === undefined || newString === undefined)) {
|
|
603
|
+
return { content: [{ type: "text", text: "❌ 参数缺失:请提供 oldString+newString(单次替换),或 edits 数组(批量原子编辑)" }], isError: true };
|
|
604
|
+
}
|
|
605
|
+
// 文件主导换行风格:所有替换文本统一转成该风格(CRLF 文件写入 CRLF,保持风格统一)
|
|
606
|
+
const fileEol = detectEol(original);
|
|
607
|
+
const toFileEol = (s) => normalizeEol(s, fileEol);
|
|
608
|
+
|
|
609
|
+
// ---------- 批量原子模式:先全部校验再统一写盘,杜绝半改状态 ----------
|
|
610
|
+
if (isBatch) {
|
|
611
|
+
let content = original;
|
|
612
|
+
let total = 0;
|
|
613
|
+
const applied = [];
|
|
614
|
+
// 全部条目按序在内存中应用;任一失败立即返回,文件保持原样
|
|
615
|
+
for (let i = 0; i < edits.length; i++) {
|
|
616
|
+
const item = edits[i];
|
|
617
|
+
const r = applySingleEdit(content, item.oldString, toFileEol(item.newString), false, item.replaceAll === true, false);
|
|
618
|
+
if (!r.ok) {
|
|
619
|
+
return {
|
|
620
|
+
content: [{
|
|
621
|
+
type: "text",
|
|
622
|
+
text: "❌ 批量编辑第 " + (i + 1) + "/" + edits.length + " 条失败: " + r.error + "\n已中止,文件未做任何修改(原子模式,前面条目也不会写入): " + filePath +
|
|
623
|
+
(r.diagnose || "") +
|
|
624
|
+
"\n提示:批量条目按顺序应用,前面条目的 newString 会改变后续条目的匹配环境,请按文件现状顺序构造。",
|
|
625
|
+
}],
|
|
626
|
+
isError: true,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
if (r.updated === content) {
|
|
630
|
+
return {
|
|
631
|
+
content: [{ type: "text", text: "❌ 批量编辑第 " + (i + 1) + "/" + edits.length + " 条替换后内容无变化(newString 与原文相同),已中止: " + filePath }],
|
|
632
|
+
isError: true,
|
|
633
|
+
};
|
|
221
634
|
}
|
|
635
|
+
content = r.updated;
|
|
636
|
+
total += r.count;
|
|
637
|
+
applied.push("#" + (i + 1) + " 替换 " + (item.replaceAll === true ? r.count : 1) + "/" + r.count + " 处");
|
|
222
638
|
}
|
|
639
|
+
if (content === original) {
|
|
640
|
+
return { content: [{ type: "text", text: "⚠️ 批量编辑应用后内容无变化,文件未修改: " + filePath }] };
|
|
641
|
+
}
|
|
642
|
+
fs.writeFileSync(filePath, (hasBom ? "\uFEFF" : "") + content, "utf-8");
|
|
643
|
+
return {
|
|
644
|
+
content: [{ type: "text", text: "✅ 批量编辑成功: " + filePath + "\n共 " + edits.length + " 条," + total + " 处替换\n" + applied.join("\n") }],
|
|
645
|
+
};
|
|
223
646
|
}
|
|
224
|
-
|
|
647
|
+
|
|
648
|
+
// ---------- 单次模式(原行为) ----------
|
|
649
|
+
const normalizedNew = toFileEol(newString);
|
|
650
|
+
const r = applySingleEdit(original, oldString, normalizedNew, useRegex === true, replaceAll === true, ignoreCase === true);
|
|
651
|
+
if (!r.ok) {
|
|
225
652
|
return {
|
|
226
|
-
content: [{ type: "text", text: "⚠️
|
|
653
|
+
content: [{ type: "text", text: "⚠️ " + r.error + ",文件未修改。请检查 oldString(或正则)是否正确: " + filePath + (r.diagnose || "") + "\n提示:换行风格(CRLF/LF)已自动兼容;重点检查空格、缩进、字符是否与原文完全一致。" }],
|
|
227
654
|
isError: true,
|
|
228
655
|
};
|
|
229
656
|
}
|
|
657
|
+
const count = r.count;
|
|
658
|
+
const updated = r.updated;
|
|
230
659
|
let warning = "";
|
|
660
|
+
if (r.eolAdapted) {
|
|
661
|
+
warning = "\nℹ️ 换行符已自动适配:oldString 与文件换行风格不一致(CRLF/LF),已按换行归一化匹配完成替换。";
|
|
662
|
+
}
|
|
231
663
|
if (!replaceAll && count > 1) {
|
|
232
|
-
warning
|
|
664
|
+
warning += "\n⚠️ 注意:共匹配 " + count + " 处,但 replaceAll=false 仅替换了第一处。如需全部替换请设 replaceAll=true。";
|
|
233
665
|
}
|
|
234
666
|
if (updated === original) {
|
|
235
667
|
return { content: [{ type: "text", text: "⚠️ 替换后内容无变化,文件未修改: " + filePath }] };
|
|
236
668
|
}
|
|
237
|
-
|
|
669
|
+
// 原文件带 BOM 时补回,保持文件编码特征不变(部分 Windows 软件依赖 BOM)
|
|
670
|
+
fs.writeFileSync(filePath, (hasBom ? "\uFEFF" : "") + updated, "utf-8");
|
|
238
671
|
return {
|
|
239
672
|
content: [{ type: "text", text: "✅ 替换成功: " + filePath + "\n替换 " + (replaceAll ? count : 1) + "/" + count + " 处" + warning }],
|
|
240
673
|
};
|
|
@@ -250,17 +683,18 @@ server.tool(
|
|
|
250
683
|
// 注册 search_files 工具(内容搜索,替代受加密影响的 Grep)
|
|
251
684
|
server.tool(
|
|
252
685
|
"search_files",
|
|
253
|
-
"
|
|
686
|
+
"在指定目录递归搜索文件内容(明文)。支持 include glob 过滤(*.java 或 **/*.js 均可,多个用逗号分隔);自动跳过二进制文件、超大文件(>5MB)、常见依赖/构建目录与隐藏文件(如 .env)。加密软件环境下内置 Grep(ripgrep) 只能读到密文搜不到内容,本工具用 Node.js fs 读取后正则匹配。替代内置 Grep 工具。注意:逐行匹配,不支持跨行正则。",
|
|
254
687
|
{
|
|
255
|
-
pattern: z.string().describe("正则表达式(如 log.*Error、function\\s+\\w
|
|
256
|
-
path: z.string().describe("
|
|
257
|
-
include: z.string().optional().describe("
|
|
688
|
+
pattern: z.string().describe("正则表达式(如 log.*Error、function\\s+\\w+),按行匹配,不支持跨行"),
|
|
689
|
+
path: z.string().describe("搜索根目录(或单个文件),支持相对路径或绝对路径"),
|
|
690
|
+
include: z.string().optional().describe("glob 过滤,多个用逗号分隔。支持文件名(*.java)与带目录通配的形式(src/**/*.js、**/*.test.ts)"),
|
|
691
|
+
exclude: z.string().optional().describe("额外排除的目录名,逗号分隔(默认已排除 node_modules/.git/target/build/dist 等)"),
|
|
258
692
|
ignoreCase: z.boolean().optional().describe("是否忽略大小写,默认 false"),
|
|
259
693
|
onlyMatching: z.boolean().optional().describe("是否只输出匹配部分(非整行),默认 false 输出整行"),
|
|
260
|
-
maxResults: z.number().optional().describe("
|
|
694
|
+
maxResults: z.number().int().positive().optional().describe("最大返回匹配数(正整数),默认 200。超过会在末尾提示被截断"),
|
|
261
695
|
},
|
|
262
696
|
{ readOnlyHint: true },
|
|
263
|
-
async ({ pattern, path: rootDir, include, ignoreCase, onlyMatching, maxResults }) => {
|
|
697
|
+
async ({ pattern, path: rootDir, include, exclude, ignoreCase, onlyMatching, maxResults }) => {
|
|
264
698
|
try {
|
|
265
699
|
// 修正:忽略大小写时需同时携带 g 与 i 标志,否则 ignoreCase 参数失效
|
|
266
700
|
const flags = ignoreCase ? "gi" : "g";
|
|
@@ -273,26 +707,48 @@ server.tool(
|
|
|
273
707
|
const includeList = include
|
|
274
708
|
? include.split(",").map((s) => s.trim()).filter(Boolean)
|
|
275
709
|
: null;
|
|
276
|
-
// include glob
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
710
|
+
// include glob 匹配:对「相对根目录的 posix 路径」做全路径匹配,
|
|
711
|
+
// 同时兼容 basename 命中,**/*.js 与 src/**/*.js 均可正确工作
|
|
712
|
+
const includeRegexes = includeList
|
|
713
|
+
? includeList.map((pat) => ({ pat, re: globToRegex(pat.replace(/\\/g, "/")) }))
|
|
714
|
+
: null;
|
|
715
|
+
const matchesInclude = (full) => {
|
|
716
|
+
if (!includeRegexes) return true;
|
|
717
|
+
const rel = path.relative(rootDir, full).replace(/\\/g, "/");
|
|
718
|
+
return includeRegexes.some(({ re }) => re.test(rel));
|
|
283
719
|
};
|
|
720
|
+
// 默认忽略目录:依赖/构建产物/IDE 缓存,可经 exclude 追加
|
|
721
|
+
const DEFAULT_IGNORE = ["node_modules", ".git", "target", "build", "dist", ".idea", ".vscode", ".svn", "bin", "obj", "out", "vendor"];
|
|
722
|
+
const excludeSet = new Set(DEFAULT_IGNORE);
|
|
723
|
+
if (exclude) {
|
|
724
|
+
for (const name of exclude.split(",").map((s) => s.trim()).filter(Boolean)) excludeSet.add(name);
|
|
725
|
+
}
|
|
284
726
|
const limit = maxResults || 200;
|
|
285
727
|
const results = [];
|
|
286
728
|
let truncated = false;
|
|
287
729
|
let scanned = 0;
|
|
730
|
+
let skippedBinary = 0;
|
|
731
|
+
let skippedLarge = 0;
|
|
288
732
|
let matchedFiles = 0;
|
|
289
733
|
// 对单个文件做内容匹配, 复用与目录遍历相同的行级逻辑
|
|
290
|
-
const scanFile = (full) => {
|
|
291
|
-
if (!matchesInclude(
|
|
734
|
+
const scanFile = (full, size) => {
|
|
735
|
+
if (!matchesInclude(full)) return;
|
|
292
736
|
scanned++;
|
|
737
|
+
// 超大文件直接跳过:minified 产物/大日志读入+正则可能卡死同步 server
|
|
738
|
+
if (size > SCAN_MAX_BYTES) { skippedLarge++; return; }
|
|
293
739
|
let content;
|
|
294
740
|
try {
|
|
295
|
-
|
|
741
|
+
// 真首块嗅探:只读前 8KB 判二进制,避免为嗅探而整读大文件
|
|
742
|
+
const sniff = readFilePrefix(full, BINARY_SNIFF_BYTES);
|
|
743
|
+
if (!sniff.ok) return;
|
|
744
|
+
if (isBinaryBuffer(sniff.firstBytes)) { skippedBinary++; return; }
|
|
745
|
+
if (sniff.isTruncated) {
|
|
746
|
+
// 文件大于嗅探预算:余量部分整体读取后拼接(5MB 上限保证内存可控)
|
|
747
|
+
content = sniff.text + fs.readFileSync(full, "utf-8").slice(sniff.text.length);
|
|
748
|
+
} else {
|
|
749
|
+
// 文件整体在预算内:首块即全文,避免第二次 IO
|
|
750
|
+
content = sniff.text;
|
|
751
|
+
}
|
|
296
752
|
} catch (e) {
|
|
297
753
|
return;
|
|
298
754
|
}
|
|
@@ -333,12 +789,16 @@ server.tool(
|
|
|
333
789
|
}
|
|
334
790
|
for (const entry of entries) {
|
|
335
791
|
if (truncated) return;
|
|
792
|
+
// 隐藏文件/目录默认跳过(.env/.gitignore 等可能含密钥,且多为配置噪音)
|
|
793
|
+
if (entry.name.startsWith(".")) continue;
|
|
336
794
|
const full = path.join(dir, entry.name);
|
|
337
795
|
if (entry.isDirectory()) {
|
|
338
|
-
if (
|
|
796
|
+
if (excludeSet.has(entry.name)) continue;
|
|
339
797
|
walk(full);
|
|
340
798
|
} else if (entry.isFile()) {
|
|
341
|
-
|
|
799
|
+
let size = 0;
|
|
800
|
+
try { size = fs.statSync(full).size; } catch (e) { /* stat 失败按 0 处理,交给 readFileSync 报错 */ }
|
|
801
|
+
scanFile(full, size);
|
|
342
802
|
}
|
|
343
803
|
}
|
|
344
804
|
};
|
|
@@ -355,16 +815,22 @@ server.tool(
|
|
|
355
815
|
return { content: [{ type: "text", text: "❌ 无法访问路径: " + e.message }], isError: true };
|
|
356
816
|
}
|
|
357
817
|
if (stat.isFile()) {
|
|
358
|
-
scanFile(rootDir);
|
|
818
|
+
scanFile(rootDir, stat.size);
|
|
359
819
|
} else if (stat.isDirectory()) {
|
|
360
820
|
walk(rootDir);
|
|
361
821
|
}
|
|
362
822
|
let text = results.join("\n");
|
|
363
823
|
if (results.length === 0) {
|
|
364
|
-
|
|
824
|
+
let parts = ["未找到匹配项(扫描 " + scanned + " 个文件,根目录: " + rootDir + ")"];
|
|
825
|
+
if (skippedBinary) parts.push("跳过二进制文件 " + skippedBinary + " 个");
|
|
826
|
+
if (skippedLarge) parts.push("跳过超大文件(" + (SCAN_MAX_BYTES / 1024 / 1024) + "MB+) " + skippedLarge + " 个");
|
|
827
|
+
text = parts.join(",");
|
|
365
828
|
} else {
|
|
366
829
|
text = "找到 " + results.length + " 处匹配(" + matchedFiles + " 个文件,扫描 " + scanned + " 个文件):\n" + text;
|
|
367
830
|
if (truncated) text += "\n... 结果已达上限 " + limit + ",被截断。可通过 maxResults 调大。";
|
|
831
|
+
if (skippedBinary || skippedLarge) {
|
|
832
|
+
text += "\nℹ️ 已跳过: 二进制文件 " + skippedBinary + " 个,超大文件 " + skippedLarge + " 个。";
|
|
833
|
+
}
|
|
368
834
|
}
|
|
369
835
|
return { content: [{ type: "text", text }] };
|
|
370
836
|
} catch (e) {
|
|
@@ -391,17 +857,35 @@ server.tool(
|
|
|
391
857
|
// 注册 file_info 工具(查询文件/目录信息)
|
|
392
858
|
server.tool(
|
|
393
859
|
"file_info",
|
|
394
|
-
"
|
|
860
|
+
"查询文件或目录的信息:是否存在、类型、大小、修改时间、符号链接等。注意:加密环境下 stat.size 反映的是密文字节数(与明文不一致),文件场景请以 sizePlaintext(明文字节数)为准。",
|
|
395
861
|
{ path: z.string().describe("文件或目录路径,支持相对路径或绝对路径") },
|
|
396
862
|
{ readOnlyHint: true },
|
|
397
863
|
async ({ path: filePath }) => {
|
|
398
864
|
try {
|
|
399
|
-
|
|
865
|
+
// 用 lstat 不跟随符号链接:坏链接可区分「链接存在但目标丢失」与「真不存在」
|
|
866
|
+
const lstat = fs.lstatSync(filePath);
|
|
867
|
+
const isSymlink = lstat.isSymbolicLink();
|
|
868
|
+
let stat = lstat;
|
|
869
|
+
let targetInfo = null;
|
|
870
|
+
if (isSymlink) {
|
|
871
|
+
try {
|
|
872
|
+
stat = fs.statSync(filePath); // 跟随链接取真实目标信息
|
|
873
|
+
targetInfo = stat.isDirectory() ? "directory" : "file";
|
|
874
|
+
} catch (e) {
|
|
875
|
+
targetInfo = "broken(目标不存在)";
|
|
876
|
+
}
|
|
877
|
+
}
|
|
400
878
|
const info = {
|
|
401
879
|
path: filePath,
|
|
402
880
|
exists: true,
|
|
403
|
-
type:
|
|
404
|
-
|
|
881
|
+
type: isSymlink ? "symlink" : lstat.isDirectory() ? "directory" : "file",
|
|
882
|
+
...(isSymlink ? { symlinkTarget: fs.readlinkSync(filePath), targetType: targetInfo } : {}),
|
|
883
|
+
// 密文字节数:加密环境下的磁盘占用,与明文大小不一致
|
|
884
|
+
sizeOnDisk: stat.size,
|
|
885
|
+
// 明文字节数:仅普通文件场景提供;读取失败(非 UTF-16 拒绝/权限等)置 null 而非误导性的 0
|
|
886
|
+
sizePlaintext: !isSymlink && !lstat.isDirectory()
|
|
887
|
+
? (() => { const r = readFileContent(filePath); return r.ok ? r.size : null; })()
|
|
888
|
+
: null,
|
|
405
889
|
modifiedTime: stat.mtime.toISOString(),
|
|
406
890
|
createdTime: stat.birthtime.toISOString(),
|
|
407
891
|
};
|
|
@@ -418,11 +902,280 @@ server.tool(
|
|
|
418
902
|
// 注册 check_status 工具
|
|
419
903
|
server.tool(
|
|
420
904
|
"check_status",
|
|
421
|
-
"
|
|
422
|
-
{
|
|
905
|
+
"检查文件操作工具的运行状态。可选提供 path 参数做实测:真实读取该文件验证 Node.js 白名单解密能力(读到明文返回成功;不传则只做基础心跳检查,不验证解密)。",
|
|
906
|
+
{
|
|
907
|
+
path: z.string().optional().describe("可选。提供时实际读取该文件验证明文可读性(建议传一个已知的加密文件)"),
|
|
908
|
+
},
|
|
423
909
|
{ readOnlyHint: true },
|
|
424
|
-
async () => {
|
|
425
|
-
|
|
910
|
+
async ({ path: filePath }) => {
|
|
911
|
+
let base = "✅ read-file-server 运行中\n平台: Node.js " + process.version + "\n版本: " + pkg.version + "\n功能: 通过 Node.js fs 读写文件明文(加密软件白名单中的 Node.js 进程自动解密/加密)";
|
|
912
|
+
if (filePath === undefined) {
|
|
913
|
+
base += "\n提示: 传入 path 参数可实测解密能力(本次未做实测)";
|
|
914
|
+
return { content: [{ type: "text", text: base }] };
|
|
915
|
+
}
|
|
916
|
+
// 实测模式:真实读一次文件,验证白名单解密链路
|
|
917
|
+
const result = readFileContent(filePath, 200);
|
|
918
|
+
if (result.ok) {
|
|
919
|
+
return {
|
|
920
|
+
content: [{
|
|
921
|
+
type: "text",
|
|
922
|
+
text: base + "\n\n实测: 已成功读取 " + filePath + "(前 " + Math.min(result.content.length, 200) + " 字符,明文大小 " + result.size + " 字节)\n结论: Node.js 解密能力正常。",
|
|
923
|
+
}],
|
|
924
|
+
};
|
|
925
|
+
} else {
|
|
926
|
+
return {
|
|
927
|
+
content: [{ type: "text", text: base + "\n\n实测: 读取 " + filePath + " 失败 -- " + result.error + "\n结论: Node.js 可能不在加密软件白名单,请联系管理员将 node.exe 加入白名单。" }],
|
|
928
|
+
isError: true,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
// 注册 list_directory 工具(列目录,替代内置 LS)
|
|
935
|
+
server.tool(
|
|
936
|
+
"list_directory",
|
|
937
|
+
"列出指定目录的内容(文件与子目录清单),加密环境下替代内置 LS / bash ls。每项含名称、类型(file/directory/symlink)、大小与修改时间;默认不显示隐藏项。",
|
|
938
|
+
{
|
|
939
|
+
path: z.string().describe("目录路径,支持相对路径或绝对路径"),
|
|
940
|
+
showHidden: z.boolean().optional().describe("是否包含以 . 开头的隐藏项,默认 false"),
|
|
941
|
+
},
|
|
942
|
+
{ readOnlyHint: true },
|
|
943
|
+
async ({ path: dirPath, showHidden }) => {
|
|
944
|
+
try {
|
|
945
|
+
const stat = fs.statSync(dirPath);
|
|
946
|
+
if (!stat.isDirectory()) {
|
|
947
|
+
return { content: [{ type: "text", text: "❌ 路径不是目录: " + dirPath }], isError: true };
|
|
948
|
+
}
|
|
949
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
950
|
+
const lines = [];
|
|
951
|
+
for (const entry of entries) {
|
|
952
|
+
if (!showHidden && entry.name.startsWith(".")) continue;
|
|
953
|
+
const full = path.join(dirPath, entry.name);
|
|
954
|
+
let type = "file";
|
|
955
|
+
let size = "";
|
|
956
|
+
let mtime = "";
|
|
957
|
+
try {
|
|
958
|
+
const st = fs.lstatSync(full);
|
|
959
|
+
if (st.isSymbolicLink()) type = "symlink";
|
|
960
|
+
else if (st.isDirectory()) type = "directory";
|
|
961
|
+
// 注意:size 为密文字节数(加密环境),仅供参考
|
|
962
|
+
size = st.isDirectory() ? "-" : String(st.size);
|
|
963
|
+
mtime = st.mtime.toISOString().replace("T", " ").slice(0, 19);
|
|
964
|
+
} catch (e) { /* stat 失败时保留默认值 */ }
|
|
965
|
+
lines.push(String(type === "directory" ? "DIR " : "FILE").padEnd(5) + " " + size.padStart(10) + " " + mtime + " " + entry.name);
|
|
966
|
+
}
|
|
967
|
+
const header = "目录: " + dirPath + "(共 " + lines.length + " 项" + (showHidden ? "" : ",不含隐藏项") + ")";
|
|
968
|
+
return { content: [{ type: "text", text: lines.length ? header + "\n" + lines.join("\n") : header + "\n(空目录或全部被隐藏项过滤)" }] };
|
|
969
|
+
} catch (e) {
|
|
970
|
+
if (e.code === "ENOENT") {
|
|
971
|
+
return { content: [{ type: "text", text: "❌ 目录不存在: " + dirPath }], isError: true };
|
|
972
|
+
}
|
|
973
|
+
return { content: [{ type: "text", text: "❌ 列目录失败: " + e.message }], isError: true };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
// 注册 find_files 工具(按文件名 glob 查找,替代内置 Glob)
|
|
979
|
+
server.tool(
|
|
980
|
+
"find_files",
|
|
981
|
+
"按文件名 glob 模式递归查找文件/目录(如 *.test.js、**/*.java、src/**/*.ts),加密环境下替代内置 Glob / bash find。默认跳过 node_modules、.git 等依赖与构建目录。",
|
|
982
|
+
{
|
|
983
|
+
pattern: z.string().describe("glob 模式,如 *.java、**/*.test.js、src/**/*.ts。* 不跨目录,** 跨目录"),
|
|
984
|
+
path: z.string().describe("搜索根目录,支持相对路径或绝对路径"),
|
|
985
|
+
maxResults: z.number().int().positive().optional().describe("最大返回条数(正整数),默认 500"),
|
|
986
|
+
},
|
|
987
|
+
{ readOnlyHint: true },
|
|
988
|
+
async ({ pattern, path: rootDir, maxResults }) => {
|
|
989
|
+
try {
|
|
990
|
+
const limit = maxResults || 500;
|
|
991
|
+
const regex = globToRegex(pattern.replace(/\\/g, "/"));
|
|
992
|
+
const results = [];
|
|
993
|
+
let truncated = false;
|
|
994
|
+
// 与 search_files 一致的默认忽略列表,另含隐藏目录
|
|
995
|
+
const IGNORE = ["node_modules", ".git", "target", "build", "dist", ".idea", ".vscode", ".svn", "bin", "obj", "out", "vendor"];
|
|
996
|
+
const walk = (dir) => {
|
|
997
|
+
if (truncated) return;
|
|
998
|
+
let entries;
|
|
999
|
+
try {
|
|
1000
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
for (const entry of entries) {
|
|
1005
|
+
if (truncated) return;
|
|
1006
|
+
if (entry.name.startsWith(".")) continue;
|
|
1007
|
+
const full = path.join(dir, entry.name);
|
|
1008
|
+
const rel = path.relative(rootDir, full).replace(/\\/g, "/");
|
|
1009
|
+
if (regex.test(rel)) {
|
|
1010
|
+
results.push((entry.isDirectory() ? "DIR " : "FILE ") + full);
|
|
1011
|
+
if (results.length >= limit) { truncated = true; return; }
|
|
1012
|
+
}
|
|
1013
|
+
if (entry.isDirectory()) {
|
|
1014
|
+
if (!IGNORE.includes(entry.name)) walk(full);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
let stat;
|
|
1019
|
+
try {
|
|
1020
|
+
stat = fs.statSync(rootDir);
|
|
1021
|
+
} catch (e) {
|
|
1022
|
+
if (e.code === "ENOENT") {
|
|
1023
|
+
return { content: [{ type: "text", text: "❌ 路径不存在: " + rootDir }], isError: true };
|
|
1024
|
+
}
|
|
1025
|
+
return { content: [{ type: "text", text: "❌ 无法访问路径: " + e.message }], isError: true };
|
|
1026
|
+
}
|
|
1027
|
+
// 根路径本身也参与匹配(如 pattern 恰好等于根目录名)
|
|
1028
|
+
if (!stat.isDirectory()) {
|
|
1029
|
+
return { content: [{ type: "text", text: "❌ 路径不是目录: " + rootDir }], isError: true };
|
|
1030
|
+
}
|
|
1031
|
+
walk(rootDir);
|
|
1032
|
+
let text;
|
|
1033
|
+
if (results.length === 0) {
|
|
1034
|
+
text = "未找到匹配 " + pattern + " 的文件(根目录: " + rootDir + ")";
|
|
1035
|
+
} else {
|
|
1036
|
+
text = "找到 " + results.length + " 个匹配(根目录: " + rootDir + "):\n" + results.join("\n");
|
|
1037
|
+
if (truncated) text += "\n... 结果已达上限 " + limit + ",被截断。可通过 maxResults 调大。";
|
|
1038
|
+
}
|
|
1039
|
+
return { content: [{ type: "text", text }] };
|
|
1040
|
+
} catch (e) {
|
|
1041
|
+
return { content: [{ type: "text", text: "❌ 查找失败: " + e.message }], isError: true };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
// 注册 copy_path 工具(复制文件/目录,替代 bash cp)
|
|
1047
|
+
server.tool(
|
|
1048
|
+
"copy_path",
|
|
1049
|
+
"复制文件或目录(目录递归复制)。加密环境下必须经 Node.js 白名单进程复制(bash cp 产出密文/双重加密文件,在白名单视图下即损坏)。目标已存在时:文件被覆盖,目录合并。",
|
|
1050
|
+
{
|
|
1051
|
+
source: z.string().describe("源路径(文件或目录)"),
|
|
1052
|
+
destination: z.string().describe("目标路径。目标已存在时文件覆盖、目录合并;不存在时自动创建"),
|
|
1053
|
+
},
|
|
1054
|
+
async ({ source, destination }) => {
|
|
1055
|
+
try {
|
|
1056
|
+
// 单次 stat 消除 existsSync+statSync 双调用的竞态窗口
|
|
1057
|
+
let srcStat;
|
|
1058
|
+
try {
|
|
1059
|
+
srcStat = fs.statSync(source);
|
|
1060
|
+
} catch (e) {
|
|
1061
|
+
if (e.code === "ENOENT") {
|
|
1062
|
+
return { content: [{ type: "text", text: "❌ 源路径不存在: " + source }], isError: true };
|
|
1063
|
+
}
|
|
1064
|
+
throw e;
|
|
1065
|
+
}
|
|
1066
|
+
// 目标为已存在目录时,将源合并/放入目标目录下(与 bash cp 的自然预期一致)
|
|
1067
|
+
let finalDest = destination;
|
|
1068
|
+
let destDirStat = null;
|
|
1069
|
+
try {
|
|
1070
|
+
destDirStat = fs.statSync(destination);
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
if (e.code !== "ENOENT") throw e;
|
|
1073
|
+
}
|
|
1074
|
+
if (destDirStat && destDirStat.isDirectory() && path.basename(source)) {
|
|
1075
|
+
finalDest = path.join(destination, path.basename(source));
|
|
1076
|
+
}
|
|
1077
|
+
let destStat = null;
|
|
1078
|
+
try {
|
|
1079
|
+
destStat = fs.statSync(finalDest);
|
|
1080
|
+
} catch (e) {
|
|
1081
|
+
if (e.code !== "ENOENT") throw e;
|
|
1082
|
+
}
|
|
1083
|
+
// 类型冲突检查:文件 -> 已有目录 或 目录 -> 已有文件,均直接报错避免误操作
|
|
1084
|
+
if (srcStat.isFile() && destStat && destStat.isDirectory()) {
|
|
1085
|
+
return { content: [{ type: "text", text: "❌ 无法复制:源是文件但目标是已存在的目录: " + finalDest }], isError: true };
|
|
1086
|
+
}
|
|
1087
|
+
if (srcStat.isDirectory() && destStat && destStat.isFile()) {
|
|
1088
|
+
return { content: [{ type: "text", text: "❌ 无法复制:源是目录但目标是已存在的文件: " + finalDest }], isError: true };
|
|
1089
|
+
}
|
|
1090
|
+
fs.cpSync(source, finalDest, { recursive: srcStat.isDirectory(), force: true });
|
|
1091
|
+
return { content: [{ type: "text", text: "✅ 复制成功: " + source + " -> " + finalDest + (srcStat.isDirectory() ? "(递归目录)" : "") }] };
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
return { content: [{ type: "text", text: "❌ 复制失败: " + e.message }], isError: true };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
);
|
|
1097
|
+
|
|
1098
|
+
// 注册 move_path 工具(移动/重命名,替代 bash mv)
|
|
1099
|
+
server.tool(
|
|
1100
|
+
"move_path",
|
|
1101
|
+
"移动或重命名文件/目录。同盘符用 rename(原子操作),跨盘符自动回退为复制后删除源。加密环境下替代 bash mv。",
|
|
1102
|
+
{
|
|
1103
|
+
source: z.string().describe("源路径(文件或目录)"),
|
|
1104
|
+
destination: z.string().describe("目标路径。目标已存在的目录则移入其下;目标已存在的文件则覆盖"),
|
|
1105
|
+
},
|
|
1106
|
+
async ({ source, destination }) => {
|
|
1107
|
+
try {
|
|
1108
|
+
// 单次 stat 消除双调用竞态
|
|
1109
|
+
let srcStat;
|
|
1110
|
+
try {
|
|
1111
|
+
srcStat = fs.statSync(source);
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
if (e.code === "ENOENT") {
|
|
1114
|
+
return { content: [{ type: "text", text: "❌ 源路径不存在: " + source }], isError: true };
|
|
1115
|
+
}
|
|
1116
|
+
throw e;
|
|
1117
|
+
}
|
|
1118
|
+
let finalDest = destination;
|
|
1119
|
+
// 目标为已存在目录时移入其下(与 bash mv 预期一致)
|
|
1120
|
+
try {
|
|
1121
|
+
const st = fs.statSync(destination);
|
|
1122
|
+
if (st.isDirectory()) finalDest = path.join(destination, path.basename(source));
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
if (e.code !== "ENOENT") throw e;
|
|
1125
|
+
}
|
|
1126
|
+
try {
|
|
1127
|
+
fs.renameSync(source, finalDest);
|
|
1128
|
+
return { content: [{ type: "text", text: "✅ 移动成功: " + source + " -> " + finalDest }] };
|
|
1129
|
+
} catch (e) {
|
|
1130
|
+
if (e.code === "EXDEV") {
|
|
1131
|
+
// 跨盘符:rename 不可用,回退为 cp + rm
|
|
1132
|
+
fs.cpSync(source, finalDest, { recursive: srcStat.isDirectory(), force: true });
|
|
1133
|
+
fs.rmSync(source, { recursive: srcStat.isDirectory(), force: true });
|
|
1134
|
+
return { content: [{ type: "text", text: "✅ 移动成功(跨盘符,复制后删除源): " + source + " -> " + finalDest }] };
|
|
1135
|
+
}
|
|
1136
|
+
throw e;
|
|
1137
|
+
}
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
if (e.code === "ENOENT") {
|
|
1140
|
+
return { content: [{ type: "text", text: "❌ 源路径不存在: " + source }], isError: true };
|
|
1141
|
+
}
|
|
1142
|
+
return { content: [{ type: "text", text: "❌ 移动失败: " + e.message }], isError: true };
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
);
|
|
1146
|
+
|
|
1147
|
+
// 注册 remove_path 工具(删除文件/目录,替代 bash rm)
|
|
1148
|
+
server.tool(
|
|
1149
|
+
"remove_path",
|
|
1150
|
+
"删除文件或目录(目录默认递归删除,不可恢复,请谨慎使用)。加密环境下替代 bash rm。可选 recursive=false 时目录必须为空才可删除。",
|
|
1151
|
+
{
|
|
1152
|
+
path: z.string().describe("要删除的文件或目录路径"),
|
|
1153
|
+
recursive: z.boolean().optional().describe("目录是否递归删除,默认 true。false 时目录非空会报错"),
|
|
1154
|
+
},
|
|
1155
|
+
async ({ path: targetPath, recursive }) => {
|
|
1156
|
+
try {
|
|
1157
|
+
const stat = fs.statSync(targetPath);
|
|
1158
|
+
if (stat.isDirectory()) {
|
|
1159
|
+
// 递归删除前统计内容数量,写入结果让调用方有迹可查
|
|
1160
|
+
let count = 0;
|
|
1161
|
+
try {
|
|
1162
|
+
count = fs.readdirSync(targetPath).length;
|
|
1163
|
+
} catch (e) { /* 统计失败不影响删除 */ }
|
|
1164
|
+
fs.rmSync(targetPath, { recursive: recursive !== false, force: false });
|
|
1165
|
+
return { content: [{ type: "text", text: "✅ 已删除目录" + (recursive !== false && count ? "(含 " + count + " 项内容)" : "") + ": " + targetPath }] };
|
|
1166
|
+
}
|
|
1167
|
+
fs.rmSync(targetPath, { force: false });
|
|
1168
|
+
return { content: [{ type: "text", text: "✅ 已删除文件: " + targetPath }] };
|
|
1169
|
+
} catch (e) {
|
|
1170
|
+
if (e.code === "ENOENT") {
|
|
1171
|
+
return { content: [{ type: "text", text: "❌ 路径不存在: " + targetPath }], isError: true };
|
|
1172
|
+
}
|
|
1173
|
+
if (e.code === "ENOTEMPTY" || e.code === "EISDIR" || e.code === "ERR_FS_EISDIR") {
|
|
1174
|
+
// rmSync 对非空目录且 recursive=false 在 Linux 抛 ENOTEMPTY,Windows 新版 Node 抛 ERR_FS_EISDIR
|
|
1175
|
+
return { content: [{ type: "text", text: "❌ 目录非空,需 recursive=true(默认)才能递归删除: " + targetPath }], isError: true };
|
|
1176
|
+
}
|
|
1177
|
+
return { content: [{ type: "text", text: "❌ 删除失败: " + e.message }], isError: true };
|
|
1178
|
+
}
|
|
426
1179
|
}
|
|
427
1180
|
);
|
|
428
1181
|
|