mcp-read-file-server 1.9.1 → 1.9.3
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 +13 -3
- package/SKILL.md +1 -1
- package/lib/cleanup.js +34 -0
- package/lib/files.js +72 -53
- package/lib/locks.js +88 -0
- package/lib/patterns.js +99 -14
- package/lib/regex-worker.js +3 -1
- package/lib/regex.js +23 -9
- package/lib/server.js +24 -16
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
**v1.9.1 边界修复**:目录复制/移动拒绝双向祖先重叠,避免覆盖未读取的源文件;回滚失败和部分删源正确保留 `changed` / `partial`;行分页无法容纳首行时明确报错,到达请求结束行即停止解析,避免被范围外长行影响。Hono 间接依赖更新至 4.13.5。
|
|
12
12
|
|
|
13
|
+
**v1.9.2 并发与异常修复**:目录与子路径操作跨实例互斥,无关路径仍可并行;提交及锁清理错误单独记录为 `cleanupErrors`,保留真实修改状态与恢复路径;递归删除逐项记录部分结果;字面量替换在可终止工作线程中一次拼接,并在预览或提交前检查时间预算。
|
|
14
|
+
|
|
15
|
+
**v1.9.3 补充修复**:工作根保护同时识别配置别名和真实路径,并拒绝操作包含受保护根的祖先;移动失败时计入已经删除的源目录;策略探测参与父目录锁;目录操作失败保留之前子项的清理诊断;glob去重、累计限额并分批让出主线程,避免过滤条件绕过超时或阻塞心跳。
|
|
16
|
+
|
|
13
17
|
## 适用场景
|
|
14
18
|
|
|
15
19
|
电脑安装了文件加密软件(如天锐绿盾、IP-Guard、亿赛通、深信服等),磁盘上的文件是密文。AI Agent(Claude Code、Cursor、Windsurf、Cline 等)是独立进程,内置文件工具不在白名单内,只能读到密文。而 Node.js 进程在白名单内,通过 MCP Server 提供的替代工具可以正常读写明文。
|
|
@@ -40,9 +44,10 @@ AI Agent --(MCP/stdio)--> Node.js MCP Server(index.js) --(lib/ 模块)--> fs
|
|
|
40
44
|
- `index.js` 仅负责 stdio 启动与正则 worker 回收
|
|
41
45
|
- `lib/encryption.js` 目录级加密探测、写入策略决策、safeWrite 组合与独立指纹校验
|
|
42
46
|
- `lib/files.js` 路径边界、跨实例锁、可回滚提交(暂存→备份→rename→终验)、目录逐文件复制/移动
|
|
47
|
+
- `lib/locks.js` 登记路径及子树占用;`lib/cleanup.js` 保留主操作结果并收集清理错误
|
|
43
48
|
- `lib/text.js` 严格 UTF-8 增量解码与流式分页
|
|
44
49
|
- `lib/patterns.js` 无回溯 glob 与保留原索引的字符串替换
|
|
45
|
-
- `lib/regex.js` / `lib/regex-worker.js`
|
|
50
|
+
- `lib/regex.js` / `lib/regex-worker.js` 用户正则与字面量编辑在可终止 worker 中执行(单次计算默认最多 1 秒,并受请求总预算约束)
|
|
46
51
|
- `lib/server.js` 注册全部 18 个 MCP 工具,统一 structuredContent 与超时/只读包装
|
|
47
52
|
|
|
48
53
|
## 环境自适应(v1.7.0+)
|
|
@@ -115,9 +120,14 @@ write_file / edit_file / copy_path / move_path 均支持 `writePolicy` 参数:
|
|
|
115
120
|
|
|
116
121
|
- **完整载荷**:追加模式先在内存合成「原内容+新增」完整内容再走事务,纠正/重写不会丢原文与 BOM
|
|
117
122
|
- **safeWrite 失败即中止**:不再回退直写破坏原文(SAFE_WRITE_FAILED,changed=false)
|
|
118
|
-
-
|
|
123
|
+
- **跨实例锁**:使用同一 `MCP_PROFILE_DIR` 的实例经 `.mcp-file-locks/` 登记整组路径,同路径及祖先/后代相互排斥,无关路径可并行(等待 5 秒超时 FILE_BUSY);`expectedHash` 可检测其他编辑器造成的版本变化(CONFLICT)。升级到 1.9.2 时应重启全部 MCP 实例,避免旧版进程继续使用不同的锁协议
|
|
124
|
+
- **清理状态**:提交或锁清理失败会附带 `cleanupErrors`;主操作已成功时保留成功结果和真实 `changed`,错误时保留原错误及 `recoveryPath`。不要因清理告警重复追加内容
|
|
125
|
+
- **递归删除**:逐项执行,失败时返回 `changed`、`partial`(最多100项)、`removedCount`、`partialTruncated`、`failedPath`;受一万项和128层预算限制,不是整树事务
|
|
119
126
|
- **断电/强杀残留**:两次 rename 之间的极端崩溃可能留下 `.mcp-backup-*` 与 `.mcp-stage-*`,先核对内容与时间再人工恢复,禁止直接批量清理
|
|
120
127
|
- **复制/移动目录**:逐文件执行相同策略;移动先复制并二次比对指纹后再删除已验证的源文件;失败返回 `partial` 与 `sourceRetained`,不静默回退;符号链接明确拒绝;源和最终目标存在任一方向的祖先关系时拒绝,相同路径保持不变。回滚失败时 `changed=true`,`partial` 包含当前失败目标,原备份通过 `recoveryPath` 返回
|
|
128
|
+
- **移动失败的源变化**:`sourceRetained` 表示本次是否尚未删除任何源文件或源目录;`removedSourceCount`、`removedSourcePaths`(最多100项)、`removedSourcePathsTruncated` 报告已经删除的源项。失败仍保留已完成子项的 `cleanupErrors`
|
|
129
|
+
- **策略探测互斥**:`inspect_write_strategy` 持有目标父目录锁,创建和清理探测样本完成后才允许该目录被复制、移动或删除
|
|
130
|
+
- **glob总预算**:生产搜索与查找使用异步匹配,合并重复模式和分支;一次请求的展开后累计长度最多10万,编译和全部路径匹配共用5000万工作单元预算。约每16384工作单元让出事件循环,检查取消与截止时间;超过限额返回 `GLOB_LIMIT`,超时返回 `TIMEOUT`
|
|
121
131
|
- **diskState 三态**:`plaintext`(独立进程验证磁盘明文)、`preserved`(保持加密直写)、`unknown`(内容已校验但无独立读取器证明磁盘状态——不能当作「已证明明文」)
|
|
122
132
|
|
|
123
133
|
## 文件结构
|
|
@@ -524,7 +534,7 @@ v1.9.0 起 safeWrite 失败**不再回退直写**(保护原文件,返回 `ch
|
|
|
524
534
|
|
|
525
535
|
### 写工具返回 FILE_BUSY / CONFLICT
|
|
526
536
|
|
|
527
|
-
- `FILE_BUSY`:另一个 MCP
|
|
537
|
+
- `FILE_BUSY`:另一个 MCP 实例正在操作同一路径、父目录或子路径(锁位于 `~/.mcp-file-locks/`)。确认对方进程结束后重试;异常退出时仅在核对记录中的 PID 后处理残留 `.lock` 或 `.registry-guard`。`LOCK_CLEANUP_FAILED` 和 `cleanupErrors` 会给出未清理路径,不应直接清空整个锁目录
|
|
528
538
|
- `CONFLICT`:传入的 `expectedHash` 与文件当前指纹不一致——文件在您读取后被其他进程改过。重新 read_file 后再编辑
|
|
529
539
|
|
|
530
540
|
### 回滚失败返回 recoveryPath
|
package/SKILL.md
CHANGED
|
@@ -20,7 +20,7 @@ description: 在Node.js为加密软件白名单进程的环境中,使用文件
|
|
|
20
20
|
## 必须理解的结果
|
|
21
21
|
|
|
22
22
|
1. 优先读取structuredContent的ok、code、changed、data和warnings,不能只看人类文本。
|
|
23
|
-
2. isError=true时可能存在目录操作部分目标;查看partial/sourceRetained。单文件回滚失败时查看recoveryPath并保留备份。
|
|
23
|
+
2. isError=true时可能存在目录操作部分目标;查看changed/partial/sourceRetained。递归删除还需查看removedCount/partialTruncated/failedPath。单文件回滚失败时查看recoveryPath并保留备份。cleanupErrors只是清理诊断,成功修改后不得因清理告警重复追加。
|
|
24
24
|
3. contentVerified表示Node可见内容一致;diskVerified才表示独立读取器的完整磁盘指纹一致。unknown不能声称已落盘明文。
|
|
25
25
|
4. check_status基础调用不探测环境。文件可读取也不能直接推断解密正常,可信expectedHash匹配才提供明确内容对照。
|
|
26
26
|
5. 文本工具只支持有效UTF8;UTF16、GBK、非法字节或NUL被拒绝时,必须先明确转换编码,不能强制按UTF8写回。
|
package/lib/cleanup.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** 清理只收集自身错误,不覆盖主操作结果或恢复信息。 */
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const { READ_LIMIT, safeEnd } = require('./text');
|
|
4
|
+
|
|
5
|
+
/** 尝试删除全部指定临时文件,并返回有界的清理诊断。 */
|
|
6
|
+
async function cleanupFiles(paths) {
|
|
7
|
+
const errors = [];
|
|
8
|
+
for (const file of paths) {
|
|
9
|
+
try { await fs.rm(file, { force: true }); }
|
|
10
|
+
catch (error) { errors.push({ path: file, code: error.code || 'CLEANUP_FAILED', message: error.message }); }
|
|
11
|
+
}
|
|
12
|
+
return errors;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 失败保留主异常,成功则附加清理告警;兼容文件结果与MCP响应。 */
|
|
16
|
+
function finishCleanup(value, failure, errors) {
|
|
17
|
+
if (failure) {
|
|
18
|
+
if (errors.length) failure.cleanupErrors = [...(failure.cleanupErrors || []), ...errors].slice(0, 100);
|
|
19
|
+
throw failure;
|
|
20
|
+
}
|
|
21
|
+
if (!errors.length) return value;
|
|
22
|
+
const response = value.structuredContent;
|
|
23
|
+
const data = response ? response.data : value;
|
|
24
|
+
data.cleanupErrors = [...(data.cleanupErrors || []), ...errors].slice(0, 100);
|
|
25
|
+
const warning = '操作已完成,但有临时文件或锁未能清理;请查看cleanupErrors,不要重复提交已完成的修改';
|
|
26
|
+
if (response) {
|
|
27
|
+
response.warnings = [...response.warnings, warning];
|
|
28
|
+
const body = value.content.find(item => item.type === 'text');
|
|
29
|
+
if (body) body.text = body.text.slice(0, safeEnd(body.text, READ_LIMIT)) + '\n注意:' + warning;
|
|
30
|
+
} else value.warnings = [...(value.warnings || []), warning];
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { cleanupFiles, finishCleanup };
|
package/lib/files.js
CHANGED
|
@@ -4,18 +4,9 @@ const nativeFs = require('node:fs');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const crypto = require('node:crypto');
|
|
6
6
|
const { pipeline } = require('node:stream/promises');
|
|
7
|
-
const { setTimeout: delay } = require('node:timers/promises');
|
|
8
|
-
const { performance } = require('node:perf_hooks');
|
|
9
7
|
const { fault, fingerprint, payloadFingerprint, checkBudget } = require('./text');
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function pathKey(file) { return process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file); }
|
|
13
|
-
|
|
14
|
-
/** 判定 candidate 是否等于 base 或位于其子树,避免字符串前缀误判。 */
|
|
15
|
-
function inside(base, candidate) {
|
|
16
|
-
const rel = path.relative(pathKey(base), pathKey(candidate));
|
|
17
|
-
return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
|
|
18
|
-
}
|
|
8
|
+
const { createLocker, pathKey, inside } = require('./locks');
|
|
9
|
+
const { cleanupFiles, finishCleanup } = require('./cleanup');
|
|
19
10
|
|
|
20
11
|
/** 从最近存在的祖先解析真实路径,识别目录符号链接和 junction 越界。 */
|
|
21
12
|
async function canonical(file) {
|
|
@@ -41,6 +32,8 @@ function createFiles(encryption, options = {}) {
|
|
|
41
32
|
const configuredRoots = options.allowedRoots ?? (process.env.MCP_ALLOWED_ROOTS ? JSON.parse(process.env.MCP_ALLOWED_ROOTS) : []);
|
|
42
33
|
if (!Array.isArray(configuredRoots) || !configuredRoots.every(x => typeof x === 'string' && path.isAbsolute(x))) throw fault('INVALID_CONFIG', 'MCP_ALLOWED_ROOTS 必须是绝对路径 JSON 数组');
|
|
43
34
|
const rootsPromise = Promise.all(configuredRoots.map(canonical));
|
|
35
|
+
const locked = createLocker(encryption.stateDir);
|
|
36
|
+
let realBasePromise;
|
|
44
37
|
|
|
45
38
|
/** 在操作前解析路径并执行服务端权限范围检查,删除链接时只解析其父目录。 */
|
|
46
39
|
async function resolve(value, { write = false, leaf = false, destructive = false } = {}) {
|
|
@@ -50,40 +43,16 @@ function createFiles(encryption, options = {}) {
|
|
|
50
43
|
const real = leaf ? path.join(await canonical(path.dirname(absolute)), path.basename(absolute)) : await canonical(absolute);
|
|
51
44
|
const roots = await rootsPromise;
|
|
52
45
|
if (roots.length && !roots.some(root => inside(root, real))) throw fault('PATH_OUTSIDE_ROOTS', '路径不在允许的根目录内');
|
|
53
|
-
if (destructive
|
|
54
|
-
|
|
46
|
+
if (destructive) {
|
|
47
|
+
// 同时保护配置别名和真实工作根;其他链接仍按leaf语义只删除链接本身。
|
|
48
|
+
const realBase = await (realBasePromise ||= canonical(baseDir));
|
|
49
|
+
if ([path.parse(real).root, baseDir, realBase, ...roots].some(root => inside(real, root))) {
|
|
50
|
+
throw fault('PROTECTED_ROOT', '不能删除或移动盘符根、工作目录、配置根目录或包含这些根的祖先目录');
|
|
51
|
+
}
|
|
55
52
|
}
|
|
56
53
|
return real;
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
/** 为多个路径按稳定顺序获取独占锁,避免多实例覆盖和死锁。 */
|
|
60
|
-
async function locked(files, action, context = {}) {
|
|
61
|
-
const dir = path.join(encryption.stateDir, '.mcp-file-locks');
|
|
62
|
-
await fs.mkdir(dir, { recursive: true });
|
|
63
|
-
const held = [];
|
|
64
|
-
const deadline = Math.min(context.deadline ?? Infinity, performance.now() + 5000);
|
|
65
|
-
try {
|
|
66
|
-
for (const file of [...new Set(files.map(pathKey))].sort()) {
|
|
67
|
-
const lock = path.join(dir, crypto.createHash('sha256').update(file).digest('hex') + '.lock');
|
|
68
|
-
while (true) {
|
|
69
|
-
checkBudget(context.signal, context.deadline);
|
|
70
|
-
try {
|
|
71
|
-
const handle = await fs.open(lock, 'wx', 0o600);
|
|
72
|
-
held.push(lock);
|
|
73
|
-
try { await handle.writeFile(JSON.stringify({ pid: process.pid, file, createdAt: new Date().toISOString() })); }
|
|
74
|
-
finally { await handle.close(); }
|
|
75
|
-
break;
|
|
76
|
-
} catch (error) {
|
|
77
|
-
if (error.code !== 'EEXIST') throw error;
|
|
78
|
-
if (performance.now() > deadline) throw fault('FILE_BUSY', '文件正被其他操作占用;若进程异常退出,请核对锁文件: ' + lock);
|
|
79
|
-
await delay(30);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return await action();
|
|
84
|
-
} finally { for (const lock of held.reverse()) await fs.rm(lock, { force: true }); }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
56
|
/** 取得文件修改前指纹,不把目录或特殊设备当作普通文件写入。 */
|
|
88
57
|
async function previous(file, context) {
|
|
89
58
|
const stat = await statMaybe(file);
|
|
@@ -118,6 +87,8 @@ function createFiles(encryption, options = {}) {
|
|
|
118
87
|
let backedUp = false;
|
|
119
88
|
let installed = false;
|
|
120
89
|
let retainBackup = false;
|
|
90
|
+
let outcome;
|
|
91
|
+
let failure;
|
|
121
92
|
try {
|
|
122
93
|
const prepared = await encryption.prepare(stage, writer, expected, decision, options);
|
|
123
94
|
// Windows FlushFileBuffers需要可写句柄,刷盘后再恢复原权限。
|
|
@@ -131,7 +102,7 @@ function createFiles(encryption, options = {}) {
|
|
|
131
102
|
installed = true;
|
|
132
103
|
const verified = await encryption.verify(file, expected, prepared.via ? 'plaintext' : decision.mode, options);
|
|
133
104
|
if (backedUp) { await fs.rm(backup); backedUp = false; }
|
|
134
|
-
|
|
105
|
+
outcome = { changed: true, hash: expected.hash, size: expected.size, ...verified, strategy: decision, ...(prepared.via ? { via: prepared.via } : {}), warnings: verified.diskState === 'unknown' ? ['内容已校验,但没有独立读取器证明磁盘为明文'] : [] };
|
|
135
106
|
} catch (error) {
|
|
136
107
|
// 回滚不受已取消的请求预算影响,优先恢复原始文件。
|
|
137
108
|
try {
|
|
@@ -145,11 +116,10 @@ function createFiles(encryption, options = {}) {
|
|
|
145
116
|
error.recoveryPath = backedUp ? backup : null;
|
|
146
117
|
error.rollbackError = rollbackError.message;
|
|
147
118
|
}
|
|
148
|
-
|
|
149
|
-
} finally {
|
|
150
|
-
await fs.rm(stage, { force: true });
|
|
151
|
-
if (!retainBackup && backedUp) await fs.rm(backup, { force: true });
|
|
119
|
+
failure = error;
|
|
152
120
|
}
|
|
121
|
+
const cleanupErrors = await cleanupFiles([stage, ...(!retainBackup && backedUp ? [backup] : [])]);
|
|
122
|
+
return finishCleanup(outcome, failure, cleanupErrors);
|
|
153
123
|
}
|
|
154
124
|
|
|
155
125
|
/** 提交文本载荷,写入者使用独占创建防止临时名冲突。 */
|
|
@@ -185,6 +155,12 @@ function createFiles(encryption, options = {}) {
|
|
|
185
155
|
const createdDirectories = [];
|
|
186
156
|
const sourceDirectories = [];
|
|
187
157
|
let removedSources = 0;
|
|
158
|
+
const removedSourcePaths = [];
|
|
159
|
+
/** 文件和目录仅在真实删除成功后计数,保留有界的源变化清单。 */
|
|
160
|
+
function recordSourceRemoval(file) {
|
|
161
|
+
removedSources++;
|
|
162
|
+
if (removedSourcePaths.length < 100) removedSourcePaths.push(file);
|
|
163
|
+
}
|
|
188
164
|
/** 逐项遍历,不跟随符号链接,避免跨根和意外递归。 */
|
|
189
165
|
async function visit(src, dst) {
|
|
190
166
|
checkBudget(settings.signal, settings.deadline);
|
|
@@ -223,30 +199,73 @@ function createFiles(encryption, options = {}) {
|
|
|
223
199
|
for (const item of completed) {
|
|
224
200
|
if ((await fingerprint(item.source, settings)).hash !== item.sourceHash) throw fault('CONFLICT', '源文件在删源前被修改,已保留');
|
|
225
201
|
await fs.unlink(item.source);
|
|
226
|
-
|
|
202
|
+
recordSourceRemoval(item.source);
|
|
203
|
+
}
|
|
204
|
+
for (const dir of sourceDirectories.reverse()) {
|
|
205
|
+
checkBudget(settings.signal, settings.deadline);
|
|
206
|
+
await fs.rmdir(dir);
|
|
207
|
+
recordSourceRemoval(dir);
|
|
227
208
|
}
|
|
228
|
-
for (const dir of sourceDirectories.reverse()) await fs.rmdir(dir);
|
|
229
209
|
}
|
|
230
|
-
|
|
210
|
+
const cleanupErrors = completed.flatMap(x => x.cleanupErrors || []).slice(0, 100);
|
|
211
|
+
return { changed: move || completed.some(x => x.changed) || createdDirectories.length > 0, source, destination, files: completed.length, moved: move, ...(cleanupErrors.length ? { cleanupErrors } : {}), warnings: completed.flatMap(x => x.warnings || []).slice(0, 10) };
|
|
231
212
|
} catch (error) {
|
|
232
213
|
error.changed = !!error.changed || removedSources > 0 || completed.some(x => x.changed) || createdDirectories.length > 0;
|
|
233
214
|
error.partial = [...new Set([...(error.partial || []), ...completed.map(x => x.destination)])].slice(0, 100);
|
|
234
215
|
error.sourceRetained = removedSources === 0;
|
|
216
|
+
if (move) {
|
|
217
|
+
error.removedSourceCount = removedSources;
|
|
218
|
+
error.removedSourcePaths = removedSourcePaths;
|
|
219
|
+
error.removedSourcePathsTruncated = removedSources > removedSourcePaths.length;
|
|
220
|
+
}
|
|
221
|
+
const cleanupErrors = [...completed.flatMap(x => x.cleanupErrors || []), ...(error.cleanupErrors || [])].slice(0, 100);
|
|
222
|
+
if (cleanupErrors.length) error.cleanupErrors = cleanupErrors;
|
|
235
223
|
throw error;
|
|
236
224
|
}
|
|
237
225
|
}, settings);
|
|
238
226
|
}
|
|
239
227
|
|
|
240
|
-
/**
|
|
228
|
+
/** 逐项删除并记录实际完成项,部分失败不再报告为未修改。 */
|
|
241
229
|
async function remove(value, settings = {}) {
|
|
242
230
|
if (!allowDelete) throw fault('DELETE_DISABLED', '服务已禁用删除工具');
|
|
243
231
|
const file = await resolve(value, { write: !settings.dryRun, leaf: true, destructive: true });
|
|
244
232
|
return locked([file], async () => {
|
|
245
233
|
const st = await fs.lstat(file);
|
|
246
234
|
if (settings.dryRun) return { changed: false, dryRun: true, path: file, type: st.isDirectory() ? 'directory' : st.isSymbolicLink() ? 'symlink' : 'file' };
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
235
|
+
const removed = [];
|
|
236
|
+
let removedCount = 0;
|
|
237
|
+
let visited = 0;
|
|
238
|
+
let failedPath = file;
|
|
239
|
+
/** 不跟随链接,逐层检查预算;只在系统删除成功后计数。 */
|
|
240
|
+
async function erase(target, depth = 0) {
|
|
241
|
+
failedPath = target;
|
|
242
|
+
checkBudget(settings.signal, settings.deadline);
|
|
243
|
+
if (++visited > 10000) throw fault('ITEM_LIMIT', '删除超过一万项预算');
|
|
244
|
+
if (depth > 128) throw fault('DEPTH_LIMIT', '删除目录深度超过128层');
|
|
245
|
+
await resolve(target, { write: true, leaf: true, destructive: true });
|
|
246
|
+
const current = await fs.lstat(target);
|
|
247
|
+
if (current.isDirectory()) {
|
|
248
|
+
if (settings.recursive !== false) {
|
|
249
|
+
const handle = await fs.opendir(target);
|
|
250
|
+
for await (const entry of handle) await erase(path.join(target, entry.name), depth + 1);
|
|
251
|
+
}
|
|
252
|
+
failedPath = target;
|
|
253
|
+
checkBudget(settings.signal, settings.deadline);
|
|
254
|
+
await fs.rmdir(target);
|
|
255
|
+
} else await fs.unlink(target);
|
|
256
|
+
removedCount++;
|
|
257
|
+
if (removed.length < 100) removed.push(target);
|
|
258
|
+
}
|
|
259
|
+
try { await erase(file); }
|
|
260
|
+
catch (error) {
|
|
261
|
+
error.changed = removedCount > 0;
|
|
262
|
+
error.partial = removed;
|
|
263
|
+
error.removedCount = removedCount;
|
|
264
|
+
error.partialTruncated = removedCount > removed.length;
|
|
265
|
+
error.failedPath = failedPath;
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
return { changed: true, path: file, removedCount };
|
|
250
269
|
}, settings);
|
|
251
270
|
}
|
|
252
271
|
|
package/lib/locks.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** 跨进程登记路径占用;祖先与后代互斥,无关路径可并行。 */
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const { setTimeout: delay } = require('node:timers/promises');
|
|
6
|
+
const { performance } = require('node:perf_hooks');
|
|
7
|
+
const { fault, checkBudget } = require('./text');
|
|
8
|
+
const { cleanupFiles, finishCleanup } = require('./cleanup');
|
|
9
|
+
|
|
10
|
+
/** 对Windows路径统一大小写,供路径关系和占用记录比较。 */
|
|
11
|
+
function pathKey(file) { return process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file); }
|
|
12
|
+
|
|
13
|
+
/** 按路径段判断同路径或后代,避免相似前缀误判。 */
|
|
14
|
+
function inside(base, candidate) {
|
|
15
|
+
const rel = path.relative(pathKey(base), pathKey(candidate));
|
|
16
|
+
return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 创建使用同一状态目录的跨实例锁管理器。 */
|
|
20
|
+
function createLocker(stateDir) {
|
|
21
|
+
const dir = path.join(stateDir, '.mcp-file-locks');
|
|
22
|
+
const guard = path.join(dir, '.registry-guard');
|
|
23
|
+
|
|
24
|
+
/** 在短临界区内检查并原子登记整组路径,等待期间不持有其他路径锁。 */
|
|
25
|
+
async function locked(files, action, context = {}) {
|
|
26
|
+
await fs.mkdir(dir, { recursive: true });
|
|
27
|
+
const wanted = [...new Set(files.map(pathKey))].sort();
|
|
28
|
+
const deadline = Math.min(context.deadline ?? Infinity, performance.now() + 5000);
|
|
29
|
+
let lease = null;
|
|
30
|
+
let value;
|
|
31
|
+
let failure;
|
|
32
|
+
try {
|
|
33
|
+
while (!lease) {
|
|
34
|
+
checkBudget(context.signal, context.deadline);
|
|
35
|
+
if (performance.now() > deadline) throw fault('FILE_BUSY', '路径或其子树正被其他操作占用;异常退出后请核对锁目录: ' + dir);
|
|
36
|
+
let guarding = false;
|
|
37
|
+
let attemptError;
|
|
38
|
+
try {
|
|
39
|
+
const handle = await fs.open(guard, 'wx', 0o600);
|
|
40
|
+
guarding = true;
|
|
41
|
+
try { await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })); }
|
|
42
|
+
finally { await handle.close(); }
|
|
43
|
+
let conflict = false;
|
|
44
|
+
for (const entry of await fs.readdir(dir)) {
|
|
45
|
+
checkBudget(context.signal, context.deadline);
|
|
46
|
+
if (!entry.endsWith('.lock')) continue;
|
|
47
|
+
let record;
|
|
48
|
+
try { record = JSON.parse(await fs.readFile(path.join(dir, entry), 'utf8')); }
|
|
49
|
+
catch (error) {
|
|
50
|
+
// 持有者可在临界区外释放记录;已删除的记录不再冲突。
|
|
51
|
+
if (error.code === 'ENOENT') continue;
|
|
52
|
+
throw fault('FILE_BUSY', '锁记录不可读取,请核对后处理: ' + path.join(dir, entry));
|
|
53
|
+
}
|
|
54
|
+
const occupied = record?.files || (record?.file ? [record.file] : null);
|
|
55
|
+
if (!Array.isArray(occupied) || !occupied.length || !occupied.every(x => typeof x === 'string' && path.isAbsolute(x))) throw fault('FILE_BUSY', '锁记录无效,请核对: ' + path.join(dir, entry));
|
|
56
|
+
if (wanted.some(a => occupied.some(b => inside(a, b) || inside(b, a)))) { conflict = true; break; }
|
|
57
|
+
}
|
|
58
|
+
if (!conflict) {
|
|
59
|
+
const candidate = path.join(dir, crypto.randomUUID() + '.lock');
|
|
60
|
+
const handle = await fs.open(candidate, 'wx', 0o600);
|
|
61
|
+
lease = candidate;
|
|
62
|
+
try { await handle.writeFile(JSON.stringify({ pid: process.pid, files: wanted, createdAt: new Date().toISOString() })); }
|
|
63
|
+
finally { await handle.close(); }
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (guarding || error.code !== 'EEXIST') attemptError = error;
|
|
67
|
+
} finally {
|
|
68
|
+
if (guarding) {
|
|
69
|
+
try { await fs.rm(guard, { force: true }); }
|
|
70
|
+
catch (error) {
|
|
71
|
+
const diagnostic = { path: guard, code: error.code, message: error.message };
|
|
72
|
+
if (!attemptError) attemptError = fault('LOCK_CLEANUP_FAILED', '锁登记临界区无法释放,请核对: ' + guard);
|
|
73
|
+
attemptError.cleanupErrors = [...(attemptError.cleanupErrors || []), diagnostic];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (attemptError) throw attemptError;
|
|
78
|
+
if (!lease) await delay(30);
|
|
79
|
+
}
|
|
80
|
+
checkBudget(context.signal, context.deadline);
|
|
81
|
+
value = await action();
|
|
82
|
+
} catch (error) { failure = error; }
|
|
83
|
+
return finishCleanup(value, failure, await cleanupFiles(lease ? [lease] : []));
|
|
84
|
+
}
|
|
85
|
+
return locked;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { createLocker, pathKey, inside };
|
package/lib/patterns.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** glob 解析及保持原始位置的字符串编辑。 */
|
|
2
|
-
const {
|
|
2
|
+
const { setImmediate: yieldLoop } = require('node:timers/promises');
|
|
3
|
+
const { fault, normalizeEol, detectEol, EDIT_BYTES, checkBudget } = require('./text');
|
|
4
|
+
const GLOB_COMPILE_LIMIT = 100000;
|
|
5
|
+
const GLOB_WORK_LIMIT = 50000000;
|
|
6
|
+
const GLOB_YIELD_INTERVAL = 16384;
|
|
3
7
|
|
|
4
8
|
/** 按顶层逗号分隔旧版模式列表,保留花括号内的逗号。 */
|
|
5
9
|
function splitPatterns(value) {
|
|
@@ -65,6 +69,59 @@ function globMatcher(patterns) {
|
|
|
65
69
|
return relative => !matchers.length || matchers.some(m => m.alternatives.some(tokens => matchTokens(tokens, m.basename ? relative.split('/').at(-1) : relative)));
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/** 为一次完整扫描累计编译和匹配工作量,定期让出主线程处理心跳/取消。 */
|
|
73
|
+
function globBudget(options) {
|
|
74
|
+
let work = 0;
|
|
75
|
+
let sinceYield = 0;
|
|
76
|
+
/** 先扣减总预算再执行对应计算,返回是否需要让出事件循环。 */
|
|
77
|
+
function spend(units) {
|
|
78
|
+
checkBudget(options.signal, options.deadline);
|
|
79
|
+
work += units;
|
|
80
|
+
sinceYield += units;
|
|
81
|
+
if (work > GLOB_WORK_LIMIT) throw fault('GLOB_LIMIT', '本次扫描的glob累计计算超过预算,请缩小模式或扫描范围');
|
|
82
|
+
return sinceYield >= GLOB_YIELD_INTERVAL;
|
|
83
|
+
}
|
|
84
|
+
/** 允许其他MCP请求及取消事件运行,恢复后再次检查绝对截止时间。 */
|
|
85
|
+
async function pause() {
|
|
86
|
+
await yieldLoop();
|
|
87
|
+
sinceYield = 0;
|
|
88
|
+
checkBudget(options.signal, options.deadline);
|
|
89
|
+
}
|
|
90
|
+
return { spend, pause };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 生产扫描使用异步glob;预算贯穿编译、全部分支及所有路径,不随文件重置。 */
|
|
94
|
+
async function globMatcherAsync(patterns, options = {}) {
|
|
95
|
+
const budget = globBudget(options);
|
|
96
|
+
const matchers = [];
|
|
97
|
+
let compiled = 0;
|
|
98
|
+
budget.spend(0);
|
|
99
|
+
for (const pattern of new Set(splitPatterns(patterns))) {
|
|
100
|
+
if (pattern.length > 1024) throw fault('INVALID_GLOB', 'glob模式过长');
|
|
101
|
+
const normalized = pattern.replace(/\\/g, '/');
|
|
102
|
+
if (budget.spend(normalized.length)) await budget.pause();
|
|
103
|
+
const alternatives = [];
|
|
104
|
+
for (const expanded of new Set(expandBraces(normalized))) {
|
|
105
|
+
compiled += expanded.length;
|
|
106
|
+
if (compiled > GLOB_COMPILE_LIMIT) throw fault('GLOB_LIMIT', 'glob展开后的累计长度超过10万,请减少模式与分支');
|
|
107
|
+
if (budget.spend(expanded.length)) await budget.pause();
|
|
108
|
+
alternatives.push(tokenizeGlob(expanded));
|
|
109
|
+
}
|
|
110
|
+
matchers.push({ basename: !normalized.includes('/'), alternatives });
|
|
111
|
+
}
|
|
112
|
+
/** 每次匹配继续消耗同一扫描的预算,并保持已有basename/相对路径语义。 */
|
|
113
|
+
return async function matches(relative) {
|
|
114
|
+
budget.spend(0);
|
|
115
|
+
const basename = relative.split('/').at(-1);
|
|
116
|
+
for (const matcher of matchers) {
|
|
117
|
+
for (const tokens of matcher.alternatives) {
|
|
118
|
+
if (await matchTokensAsync(tokens, matcher.basename ? basename : relative, budget)) return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return !matchers.length;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
68
125
|
/** 有上限地展开花括号;不将用户模式交给可能回溯的JS正则。 */
|
|
69
126
|
function expandBraces(pattern, depth = 0) {
|
|
70
127
|
if (depth > 8) throw fault('INVALID_GLOB', 'glob嵌套超过上限');
|
|
@@ -99,20 +156,35 @@ function tokenizeGlob(pattern) {
|
|
|
99
156
|
return tokens;
|
|
100
157
|
}
|
|
101
158
|
|
|
102
|
-
/**
|
|
159
|
+
/** 计算一个token的动态规划行,供同步对照和异步生产匹配共用。 */
|
|
160
|
+
function advanceToken(previous, token, value) {
|
|
161
|
+
const current = new Uint8Array(value.length + 1);
|
|
162
|
+
let reachable = false;
|
|
163
|
+
for (let j = 0; j <= value.length; j++) {
|
|
164
|
+
if (token.type === 'star' || token.type === 'all') current[j] = previous[j] || (j > 0 && current[j - 1] && (token.type === 'all' || value[j - 1] !== '/')) ? 1 : 0;
|
|
165
|
+
else if (token.type === 'dirs') { current[j] = previous[j] || (j > 0 && value[j - 1] === '/' && reachable) ? 1 : 0; reachable ||= !!previous[j]; }
|
|
166
|
+
else if (j > 0) current[j] = previous[j - 1] && (token.type === 'one' ? value[j - 1] !== '/' : value[j - 1] === token.value) ? 1 : 0;
|
|
167
|
+
}
|
|
168
|
+
return current;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 同步匹配保留给兼容调用与语义对照,生产工具使用异步入口。 */
|
|
103
172
|
function matchTokens(tokens, value) {
|
|
173
|
+
if (tokens.length * value.length > 1000000) throw fault('GLOB_LIMIT', 'glob匹配超过计算预算');
|
|
174
|
+
let previous = new Uint8Array(value.length + 1);
|
|
175
|
+
previous[0] = 1;
|
|
176
|
+
for (const token of tokens) previous = advanceToken(previous, token, value);
|
|
177
|
+
return !!previous[value.length];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 逐行计算并共享请求总预算;一次同步片段最多一行加一个让出间隔。 */
|
|
181
|
+
async function matchTokensAsync(tokens, value, budget) {
|
|
104
182
|
if (tokens.length * value.length > 1000000) throw fault('GLOB_LIMIT', 'glob匹配超过计算预算');
|
|
105
183
|
let previous = new Uint8Array(value.length + 1);
|
|
106
184
|
previous[0] = 1;
|
|
107
185
|
for (const token of tokens) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
for (let j = 0; j <= value.length; j++) {
|
|
111
|
-
if (token.type === 'star' || token.type === 'all') current[j] = previous[j] || (j > 0 && current[j - 1] && (token.type === 'all' || value[j - 1] !== '/')) ? 1 : 0;
|
|
112
|
-
else if (token.type === 'dirs') { current[j] = previous[j] || (j > 0 && value[j - 1] === '/' && reachable) ? 1 : 0; reachable ||= !!previous[j]; }
|
|
113
|
-
else if (j > 0) current[j] = previous[j - 1] && (token.type === 'one' ? value[j - 1] !== '/' : value[j - 1] === token.value) ? 1 : 0;
|
|
114
|
-
}
|
|
115
|
-
previous = current;
|
|
186
|
+
if (budget.spend(value.length + 1)) await budget.pause();
|
|
187
|
+
previous = advanceToken(previous, token, value);
|
|
116
188
|
}
|
|
117
189
|
return !!previous[value.length];
|
|
118
190
|
}
|
|
@@ -163,13 +235,26 @@ function applyLiteral(original, edit, ignoreCase = false) {
|
|
|
163
235
|
if (!matches.count) throw fault('NO_MATCH', '未找到匹配内容;请检查空格、缩进和原文版本');
|
|
164
236
|
if (edit.expectedMatches !== undefined && matches.count !== edit.expectedMatches) throw fault('MATCH_COUNT_MISMATCH', '实际匹配数与 expectedMatches 不一致', { matched: matches.count });
|
|
165
237
|
const chosen = matches.selected;
|
|
166
|
-
|
|
167
|
-
|
|
238
|
+
const spans = [];
|
|
239
|
+
let bytes = Buffer.byteLength(original);
|
|
240
|
+
const replacementBytes = Buffer.byteLength(replacement);
|
|
241
|
+
for (const match of chosen) {
|
|
168
242
|
const start = map ? map(match.index) : match.index;
|
|
169
243
|
const end = map ? map(match.index + match[0].length) : match.index + match[0].length;
|
|
170
|
-
|
|
244
|
+
bytes += replacementBytes - Buffer.byteLength(original.slice(start, end));
|
|
245
|
+
spans.push({ start, end });
|
|
246
|
+
}
|
|
247
|
+
if (bytes > EDIT_BYTES) throw fault('FILE_TOO_LARGE', '编辑结果超过16MB预算');
|
|
248
|
+
// 预先核对输出体量,再按原文位置一次拼接,避免逐匹配复制整个文件。
|
|
249
|
+
const parts = [];
|
|
250
|
+
let cursor = 0;
|
|
251
|
+
for (const { start, end } of spans) {
|
|
252
|
+
parts.push(original.slice(cursor, start), replacement);
|
|
253
|
+
cursor = end;
|
|
171
254
|
}
|
|
255
|
+
parts.push(original.slice(cursor));
|
|
256
|
+
const updated = parts.join('');
|
|
172
257
|
return { updated, matched: matches.count, replaced: chosen.length, eolAdapted: !!map };
|
|
173
258
|
}
|
|
174
259
|
|
|
175
|
-
module.exports = { splitPatterns, escapeRegex, globToRegex, globMatcher, applyLiteral };
|
|
260
|
+
module.exports = { splitPatterns, escapeRegex, globToRegex, globMatcher, globMatcherAsync, applyLiteral };
|
package/lib/regex-worker.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/** 将用户正则隔离在可终止线程,主线程保持 MCP 心跳响应。 */
|
|
2
2
|
const { parentPort } = require('node:worker_threads');
|
|
3
|
+
const { applyLiteral } = require('./patterns');
|
|
3
4
|
|
|
4
5
|
/** 执行有限输出的匹配或编辑,超时由父线程终止整个 worker。 */
|
|
5
6
|
function execute(task) {
|
|
7
|
+
if (task.operation === 'literal_edit') return applyLiteral(task.text, task.edit, task.ignoreCase);
|
|
6
8
|
const regex = new RegExp(task.pattern, 'gm' + (task.ignoreCase ? 'i' : ''));
|
|
7
9
|
if (task.operation === 'edit') {
|
|
8
10
|
let count = 0;
|
|
@@ -39,5 +41,5 @@ function execute(task) {
|
|
|
39
41
|
|
|
40
42
|
parentPort.on('message', message => {
|
|
41
43
|
try { parentPort.postMessage({ id: message.id, result: execute(message.task) }); }
|
|
42
|
-
catch (error) { parentPort.postMessage({ id: message.id, error: error.message }); }
|
|
44
|
+
catch (error) { parentPort.postMessage({ id: message.id, error: { code: error.code || (message.task.operation === 'literal_edit' ? 'EDIT_FAILED' : 'INVALID_REGEX'), message: error.message } }); }
|
|
43
45
|
});
|
package/lib/regex.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** 正则与字面量编辑共用有界计算线程;主线程负责取消和截止时间。 */
|
|
2
2
|
const { Worker } = require('node:worker_threads');
|
|
3
3
|
const path = require('node:path');
|
|
4
|
-
const {
|
|
4
|
+
const { performance } = require('node:perf_hooks');
|
|
5
|
+
const { fault, checkBudget } = require('./text');
|
|
5
6
|
let worker = null;
|
|
6
7
|
let serial = 0;
|
|
7
8
|
const pending = new Map();
|
|
@@ -26,7 +27,8 @@ function ensureWorker() {
|
|
|
26
27
|
pending.delete(message.id);
|
|
27
28
|
clearTimeout(job.timer);
|
|
28
29
|
job.cleanup();
|
|
29
|
-
if (
|
|
30
|
+
if (performance.now() > job.deadline) job.reject(fault('TIMEOUT', '计算结果到达时已超过操作预算'));
|
|
31
|
+
else if (message.error) job.reject(fault(message.error.code, message.error.message));
|
|
30
32
|
else job.resolve(message.result);
|
|
31
33
|
if (!pending.size) current.unref();
|
|
32
34
|
});
|
|
@@ -35,21 +37,33 @@ function ensureWorker() {
|
|
|
35
37
|
return current;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
/**
|
|
39
|
-
function
|
|
40
|
-
if (task.pattern.length > 4096) return Promise.reject(fault('INVALID_REGEX', '正则超过 4096 字符'));
|
|
40
|
+
/** 分派有硬超时和队列上限的计算任务,连同排队时间一起计入预算。 */
|
|
41
|
+
function runTask(task, options = {}) {
|
|
41
42
|
if (pending.size >= 16) return Promise.reject(fault('BUSY', '正则队列已满,请缩小搜索范围'));
|
|
42
43
|
if (options.signal?.aborted) return Promise.reject(fault('CANCELLED', '操作已取消'));
|
|
43
44
|
return new Promise((resolve, reject) => {
|
|
45
|
+
checkBudget(options.signal, options.deadline);
|
|
44
46
|
const current = ensureWorker();
|
|
45
47
|
current.ref();
|
|
46
48
|
const id = ++serial;
|
|
47
49
|
const abort = () => stop(fault('CANCELLED', '正则任务已取消'));
|
|
48
|
-
const
|
|
50
|
+
const deadline = Math.min(options.deadline ?? Infinity, performance.now() + (options.timeoutMs ?? 1000));
|
|
51
|
+
const timer = setTimeout(() => stop(task.operation === 'literal_edit' ? fault('TIMEOUT', '字面量编辑超过时间预算') : fault('REGEX_TIMEOUT', '正则计算超过预算;请简化正则或使用字面量模式')), Math.max(1, deadline - performance.now()));
|
|
49
52
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
50
|
-
pending.set(id, { resolve, reject, timer, cleanup: () => options.signal?.removeEventListener('abort', abort) });
|
|
53
|
+
pending.set(id, { resolve, reject, timer, deadline, cleanup: () => options.signal?.removeEventListener('abort', abort) });
|
|
51
54
|
current.postMessage({ id, task });
|
|
52
55
|
});
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
/** 限制用户正则长度后交给工作线程执行。 */
|
|
59
|
+
function runRegex(task, options = {}) {
|
|
60
|
+
if (task.pattern.length > 4096) return Promise.reject(fault('INVALID_REGEX', '正则超过 4096 字符'));
|
|
61
|
+
return runTask(task, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 字面量保留原始索引、换行和替换语义,同样具备线程取消能力。 */
|
|
65
|
+
function runLiteral(text, edit, ignoreCase, options = {}) {
|
|
66
|
+
return runTask({ operation: 'literal_edit', text, edit, ignoreCase }, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { runRegex, runLiteral, stop };
|
package/lib/server.js
CHANGED
|
@@ -6,8 +6,8 @@ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const pkg = require('../package.json');
|
|
8
8
|
const text = require('./text');
|
|
9
|
-
const {
|
|
10
|
-
const { runRegex } = require('./regex');
|
|
9
|
+
const { globMatcherAsync, escapeRegex } = require('./patterns');
|
|
10
|
+
const { runRegex, runLiteral } = require('./regex');
|
|
11
11
|
const { createEncryption } = require('./encryption');
|
|
12
12
|
const { createFiles, statMaybe } = require('./files');
|
|
13
13
|
const IGNORE = new Set(['node_modules', '.git', 'target', 'build', 'dist', '.idea', '.vscode', '.svn', 'bin', 'obj', 'out', 'vendor']);
|
|
@@ -52,9 +52,8 @@ function createServer(options = {}) {
|
|
|
52
52
|
return await action(args, context);
|
|
53
53
|
} catch (error) {
|
|
54
54
|
return result('操作失败:' + error.message, { changed: !!error.changed,
|
|
55
|
-
...(
|
|
56
|
-
|
|
57
|
-
}, { ok: false, code: error.code || 'INTERNAL_ERROR' });
|
|
55
|
+
...Object.fromEntries(['recoveryPath', 'rollbackError', 'partial', 'sourceRetained', 'removedSourceCount', 'removedSourcePaths', 'removedSourcePathsTruncated', 'removedCount', 'partialTruncated', 'failedPath', 'cleanupErrors'].filter(key => error[key] !== undefined).map(key => [key, error[key]])),
|
|
56
|
+
}, { ok: false, code: error.code || 'INTERNAL_ERROR', warnings: error.cleanupErrors?.length ? ['部分临时文件未能清理,详见cleanupErrors'] : [] });
|
|
58
57
|
}
|
|
59
58
|
};
|
|
60
59
|
handlers[name] = handler;
|
|
@@ -127,7 +126,9 @@ function createServer(options = {}) {
|
|
|
127
126
|
let matched = 0;
|
|
128
127
|
for (const item of args.edits || [args]) {
|
|
129
128
|
text.checkBudget(context.signal, context.deadline);
|
|
130
|
-
const
|
|
129
|
+
const computeBudget = { ...context, timeoutMs: Math.max(1, Math.min(1000, context.deadline - performance.now())) };
|
|
130
|
+
const change = args.useRegex ? await runRegex({ operation: 'edit', text: updated, pattern: item.oldString, replacement: text.normalizeEol(item.newString, text.detectEol(updated)), replaceAll: item.replaceAll === true, ignoreCase: args.ignoreCase }, computeBudget) : await runLiteral(updated, item, args.ignoreCase, computeBudget);
|
|
131
|
+
text.checkBudget(context.signal, context.deadline);
|
|
131
132
|
if (!change.matched) throw text.fault('NO_MATCH', '未找到匹配内容,文件未修改');
|
|
132
133
|
if (item.expectedMatches !== undefined && item.expectedMatches !== change.matched) throw text.fault('MATCH_COUNT_MISMATCH', '实际匹配数与expectedMatches不一致');
|
|
133
134
|
updated = change.updated;
|
|
@@ -137,6 +138,7 @@ function createServer(options = {}) {
|
|
|
137
138
|
}
|
|
138
139
|
const payload = (original.hasBom ? '\uFEFF' : '') + updated;
|
|
139
140
|
const details = { matched, replaced, originalHash: before.hash, proposedHash: text.payloadFingerprint(payload).hash, preview: preview(original.content, updated) };
|
|
141
|
+
text.checkBudget(context.signal, context.deadline);
|
|
140
142
|
if (args.dryRun) return result('编辑预览完成,文件未修改', { changed: false, dryRun: true, ...details });
|
|
141
143
|
const data = await files.write(file, payload, { ...context, expectedHash: before.hash, writePolicy: args.writePolicy });
|
|
142
144
|
return result('编辑成功: ' + file + '\n实际替换 ' + replaced + ' 处,共匹配 ' + matched + ' 处', { ...data, ...details }, { warnings: data.warnings || [] });
|
|
@@ -176,7 +178,7 @@ function createServer(options = {}) {
|
|
|
176
178
|
...scanShape, pattern: z.string().max(4096), include: patternsSchema, ignoreCase: z.boolean().optional(), onlyMatching: z.boolean().optional(), mode: z.enum(['regex', 'literal']).optional(), contextLines: z.number().int().min(0).max(10).optional(),
|
|
177
179
|
}, true, async (args, context) => {
|
|
178
180
|
const root = await files.resolve(args.path);
|
|
179
|
-
const include =
|
|
181
|
+
const include = await globMatcherAsync(args.include, context);
|
|
180
182
|
const errors = [];
|
|
181
183
|
const skipped = { binary: 0, large: 0, unreadable: 0 };
|
|
182
184
|
const results = [];
|
|
@@ -186,7 +188,7 @@ function createServer(options = {}) {
|
|
|
186
188
|
let truncated = false;
|
|
187
189
|
const exclude = Array.isArray(args.exclude) ? args.exclude : (args.exclude || '').split(',').filter(Boolean);
|
|
188
190
|
for await (const entry of walk(root, { ...args, exclude }, errors, context)) {
|
|
189
|
-
if (entry.type !== 'file' || !include(entry.relative)) continue;
|
|
191
|
+
if (entry.type !== 'file' || !(await include(entry.relative))) continue;
|
|
190
192
|
if (results.length >= limit || budget <= 0) { truncated = true; break; }
|
|
191
193
|
let content;
|
|
192
194
|
try {
|
|
@@ -211,23 +213,25 @@ function createServer(options = {}) {
|
|
|
211
213
|
}
|
|
212
214
|
if (found.truncated || truncated) { truncated = true; break; }
|
|
213
215
|
}
|
|
216
|
+
text.checkBudget(context.signal, context.deadline);
|
|
214
217
|
const warnings = errors.length ? ['部分文件/目录未能扫描,详见errors和skipped;无匹配不代表这些文件没有内容'] : [];
|
|
215
218
|
return result('找到 ' + results.length + ' 处匹配(扫描 ' + scanned + ' 个文件)\n' + results.map(hit => hit.path + ':' + hit.line + ':' + hit.text).join('\n') + (truncated ? '\n结果已截断,请缩小范围' : ''), { results, scanned, skipped, errors, truncated }, { warnings, ok: scanned > 0 || !errors.length, code: scanned === 0 && errors.length ? 'SCAN_INCOMPLETE' : 'OK' });
|
|
216
219
|
});
|
|
217
220
|
register('find_files', '按glob递归查找,支持花括号、字面括号路径、隐藏项及默认忽略开关。', { ...scanShape, pattern: z.string().min(1).max(1024) }, true, async (args, context) => {
|
|
218
221
|
const root = await files.resolve(args.path);
|
|
219
|
-
const matches =
|
|
222
|
+
const matches = await globMatcherAsync([args.pattern], context);
|
|
220
223
|
const entries = [];
|
|
221
224
|
const errors = [];
|
|
222
225
|
let truncated = false;
|
|
223
226
|
let budget = text.READ_LIMIT;
|
|
224
227
|
const exclude = Array.isArray(args.exclude) ? args.exclude : (args.exclude || '').split(',').filter(Boolean);
|
|
225
228
|
for await (const entry of walk(root, { ...args, exclude }, errors, context)) {
|
|
226
|
-
if (!matches(entry.relative)) continue;
|
|
229
|
+
if (!(await matches(entry.relative))) continue;
|
|
227
230
|
budget -= entry.file.length + entry.relative.length + 64;
|
|
228
231
|
if (entries.length >= (args.maxResults ?? 500) || budget < 0) { truncated = true; break; }
|
|
229
232
|
entries.push(entry);
|
|
230
233
|
}
|
|
234
|
+
text.checkBudget(context.signal, context.deadline);
|
|
231
235
|
return result(entries.map(entry => entry.type.toUpperCase() + ' ' + entry.file).join('\n') || '未找到匹配项', { entries, errors, truncated }, { warnings: errors.length ? ['部分目录未能扫描'] : [], ok: !!entries.length || !errors.length, code: !entries.length && errors.length ? 'SCAN_INCOMPLETE' : 'OK' });
|
|
232
236
|
});
|
|
233
237
|
register('list_directory', '列目录的文件/目录/链接类型与元数据,支持分页。', { path: pathSchema, showHidden: z.boolean().optional(), offset: z.number().int().min(0).max(1000000).optional(), maxResults: z.number().int().min(1).max(2000).optional() }, true, async (args, context) => {
|
|
@@ -257,11 +261,13 @@ function createServer(options = {}) {
|
|
|
257
261
|
if (st.isFile() && args.calculateHash !== false) { const fp = await text.fingerprint(file, context); data.sizeReadable = fp.size; data.sizePlaintext = fp.size; data.hash = fp.hash; }
|
|
258
262
|
return result(JSON.stringify(data, null, 2), data);
|
|
259
263
|
});
|
|
260
|
-
register('create_directory', '递归创建目录,受只读模式与allowedRoots约束。', { path: pathSchema }, false, async args => {
|
|
264
|
+
register('create_directory', '递归创建目录,受只读模式与allowedRoots约束。', { path: pathSchema }, false, async (args, context) => {
|
|
261
265
|
const dir = await files.resolve(args.path, { write: true });
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
266
|
+
return files.locked([dir], async () => {
|
|
267
|
+
const existed = !!(await statMaybe(dir));
|
|
268
|
+
await fs.mkdir(dir, { recursive: true });
|
|
269
|
+
return result('目录已创建: ' + dir, { path: dir, changed: !existed });
|
|
270
|
+
}, context);
|
|
265
271
|
});
|
|
266
272
|
const transferShape = { source: pathSchema, destination: pathSchema, overwrite: z.boolean().optional(), writePolicy: policySchema, timeoutMs: timeoutSchema };
|
|
267
273
|
register('copy_path', '逐文件复制和验证。目标已有目录时放入其下;失败返回部分目标并保留源,不自动跟随链接。', transferShape, false, async (args, context) => {
|
|
@@ -306,8 +312,10 @@ function createServer(options = {}) {
|
|
|
306
312
|
});
|
|
307
313
|
register('inspect_write_strategy', '检查有效写入策略;在已存在目标目录创建并清理随机探测样本,不修改目标文件。', { path: pathSchema, writePolicy: policySchema, timeoutMs: timeoutSchema }, false, async (args, context) => {
|
|
308
314
|
const file = await files.resolve(args.path);
|
|
309
|
-
|
|
310
|
-
|
|
315
|
+
return files.locked([path.dirname(file)], async () => {
|
|
316
|
+
const data = await encryption.strategy(file, args.writePolicy, context);
|
|
317
|
+
return result(JSON.stringify(data, null, 2), data);
|
|
318
|
+
}, context);
|
|
311
319
|
});
|
|
312
320
|
return { server, handlers, encryption, files };
|
|
313
321
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-read-file-server",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "加密环境文件操作 MCP 工具。当 Node.js 是加密软件白名单进程时,通过 fs 模块自动解密读写文件明文,替代 AI Agent 内置文件工具,适用于任何支持 MCP 协议的 Agent。",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"start": "node index.js",
|
|
19
|
-
"test": "node --test --test-reporter=tap --test-concurrency=1 test/regression.test.js test/protocol.test.js test/adapters.test.js test/review-fixes.test.js",
|
|
19
|
+
"test": "node --test --test-reporter=tap --test-concurrency=1 test/regression.test.js test/protocol.test.js test/adapters.test.js test/review-fixes.test.js test/resilience.test.js test/followup-fixes.test.js",
|
|
20
20
|
"check": "node scripts/check.js"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|