mcp-read-file-server 1.6.0 → 1.9.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.
@@ -0,0 +1,175 @@
1
+ /** glob 解析及保持原始位置的字符串编辑。 */
2
+ const { fault, normalizeEol, detectEol } = require('./text');
3
+
4
+ /** 按顶层逗号分隔旧版模式列表,保留花括号内的逗号。 */
5
+ function splitPatterns(value) {
6
+ if (Array.isArray(value)) return value;
7
+ if (!value) return [];
8
+ const result = [];
9
+ let depth = 0;
10
+ let start = 0;
11
+ for (let i = 0; i < value.length; i++) {
12
+ if (value[i] === '{') depth++;
13
+ if (value[i] === '}') depth--;
14
+ if (depth < 0) throw fault('INVALID_GLOB', '花括号不匹配');
15
+ if (value[i] === ',' && depth === 0) { result.push(value.slice(start, i).trim()); start = i + 1; }
16
+ }
17
+ if (depth) throw fault('INVALID_GLOB', '花括号不匹配');
18
+ result.push(value.slice(start).trim());
19
+ return result.filter(Boolean);
20
+ }
21
+
22
+ /** 转义正则字面量,圆括号路径不会被解释为语法。 */
23
+ function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
24
+
25
+ /** 将有界 glob 转为正则,双星仅在完整路径段时跨目录。 */
26
+ function globToRegex(pattern) {
27
+ if (pattern.length > 1024) throw fault('INVALID_GLOB', 'glob 模式过长');
28
+ const glob = pattern.replace(/\\/g, '/');
29
+ let position = 0;
30
+ /** 递归编译花括号组并限制嵌套深度。 */
31
+ function compile(depth = 0) {
32
+ if (depth > 8) throw fault('INVALID_GLOB', 'glob 嵌套超过上限');
33
+ let result = '';
34
+ while (position < glob.length) {
35
+ const ch = glob[position];
36
+ if (depth && (ch === ',' || ch === '}')) break;
37
+ position++;
38
+ if (ch === '{') {
39
+ const branches = [compile(depth + 1)];
40
+ while (glob[position] === ',') { position++; branches.push(compile(depth + 1)); }
41
+ if (glob[position++] !== '}') throw fault('INVALID_GLOB', '花括号不匹配');
42
+ result += '(?:' + branches.join('|') + ')';
43
+ } else if (ch === '*') {
44
+ const segmentStart = position === 1 || glob[position - 2] === '/';
45
+ if (segmentStart && glob[position] === '*' && (position + 1 === glob.length || glob[position + 1] === '/')) {
46
+ position++;
47
+ if (glob[position] === '/') { position++; result += '(?:[^/]+/)*'; }
48
+ else result += '.*';
49
+ } else result += '[^/]*';
50
+ } else if (ch === '?') result += '[^/]';
51
+ else result += escapeRegex(ch);
52
+ }
53
+ return result;
54
+ }
55
+ return new RegExp('^' + compile() + '$');
56
+ }
57
+
58
+ /** 无路径分隔符的 glob 递归匹配 basename,否则匹配相对路径。 */
59
+ function globMatcher(patterns) {
60
+ const matchers = splitPatterns(patterns).map(pattern => {
61
+ if (pattern.length > 1024) throw fault('INVALID_GLOB', 'glob模式过长');
62
+ const normalized = pattern.replace(/\\/g, '/');
63
+ return { basename: !normalized.includes('/'), alternatives: expandBraces(normalized).map(tokenizeGlob) };
64
+ });
65
+ return relative => !matchers.length || matchers.some(m => m.alternatives.some(tokens => matchTokens(tokens, m.basename ? relative.split('/').at(-1) : relative)));
66
+ }
67
+
68
+ /** 有上限地展开花括号;不将用户模式交给可能回溯的JS正则。 */
69
+ function expandBraces(pattern, depth = 0) {
70
+ if (depth > 8) throw fault('INVALID_GLOB', 'glob嵌套超过上限');
71
+ const start = pattern.indexOf('{');
72
+ if (start < 0) return [pattern];
73
+ let nesting = 1;
74
+ let end = start + 1;
75
+ while (end < pattern.length && nesting) { if (pattern[end] === '{') nesting++; if (pattern[end] === '}') nesting--; end++; }
76
+ if (nesting) throw fault('INVALID_GLOB', '花括号不匹配');
77
+ const branches = splitPatterns(pattern.slice(start + 1, end - 1));
78
+ const expanded = [];
79
+ for (const branch of branches) {
80
+ expanded.push(...expandBraces(pattern.slice(0, start) + branch + pattern.slice(end), depth + 1));
81
+ if (expanded.length > 64) throw fault('GLOB_LIMIT', '花括号展开超过64项');
82
+ }
83
+ return expanded;
84
+ }
85
+
86
+ /** 将glob转换为字面量、单字符和路径段通配token。 */
87
+ function tokenizeGlob(pattern) {
88
+ const tokens = [];
89
+ for (let i = 0; i < pattern.length; i++) {
90
+ const ch = pattern[i];
91
+ if (ch === '*' && pattern[i + 1] === '*' && (i === 0 || pattern[i - 1] === '/') && (i + 2 === pattern.length || pattern[i + 2] === '/')) {
92
+ i++;
93
+ if (pattern[i + 1] === '/') { i++; tokens.push({ type: 'dirs' }); }
94
+ else tokens.push({ type: 'all' });
95
+ } else if (ch === '*') { if (tokens.at(-1)?.type !== 'star') tokens.push({ type: 'star' }); }
96
+ else if (ch === '?') tokens.push({ type: 'one' });
97
+ else tokens.push({ type: 'literal', value: ch });
98
+ }
99
+ return tokens;
100
+ }
101
+
102
+ /** 动态规划匹配glob,复杂度有明确上限,不存在灾难性回溯。 */
103
+ function matchTokens(tokens, value) {
104
+ if (tokens.length * value.length > 1000000) throw fault('GLOB_LIMIT', 'glob匹配超过计算预算');
105
+ let previous = new Uint8Array(value.length + 1);
106
+ previous[0] = 1;
107
+ for (const token of tokens) {
108
+ const current = new Uint8Array(value.length + 1);
109
+ let reachable = false;
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;
116
+ }
117
+ return !!previous[value.length];
118
+ }
119
+
120
+ /** 归一行尾并记录规范化字符在原字符串中的位置。 */
121
+ function normalizeMapped(text) {
122
+ const removed = [];
123
+ const normalized = text.replace(/\r\n|\r/g, (match, offset) => {
124
+ if (match.length === 2) removed.push(offset - removed.length);
125
+ return '\n';
126
+ });
127
+ /** 二分计算边界之前被折叠的CRLF数量,不为每个字符保存一个整数。 */
128
+ function map(position) {
129
+ let low = 0;
130
+ let high = removed.length;
131
+ while (low < high) { const mid = (low + high) >>> 1; if (removed[mid] < position) low = mid + 1; else high = mid; }
132
+ return position + low;
133
+ }
134
+ return { text: normalized, map };
135
+ }
136
+
137
+ /** 有上限地统计匹配,仅保留实际需要替换的位置。 */
138
+ function literalMatches(text, needle, ignoreCase, replaceAll) {
139
+ const selected = [];
140
+ let count = 0;
141
+ for (const match of text.matchAll(new RegExp(escapeRegex(needle), ignoreCase ? 'giu' : 'gu'))) {
142
+ if (++count > 100000) throw fault('MATCH_LIMIT', '匹配数超过十万,请缩小编辑范围');
143
+ if (replaceAll || !selected.length) selected.push(match);
144
+ }
145
+ return { selected, count };
146
+ }
147
+
148
+ /** 字面量匹配始终使用原始索引;可选换行兼容,不使用变长 toLowerCase。 */
149
+ function applyLiteral(original, edit, ignoreCase = false) {
150
+ if (!edit.oldString) throw fault('EMPTY_MATCH', 'oldString 不能为空');
151
+ const replacement = normalizeEol(edit.newString, detectEol(original));
152
+ let haystack = original;
153
+ let needle = edit.oldString;
154
+ let map = null;
155
+ let matches = literalMatches(haystack, needle, ignoreCase, edit.replaceAll);
156
+ if (!matches.count) {
157
+ const normalized = normalizeMapped(original);
158
+ haystack = normalized.text;
159
+ map = normalized.map;
160
+ needle = normalizeEol(needle, '\n');
161
+ matches = literalMatches(haystack, needle, ignoreCase, edit.replaceAll);
162
+ }
163
+ if (!matches.count) throw fault('NO_MATCH', '未找到匹配内容;请检查空格、缩进和原文版本');
164
+ if (edit.expectedMatches !== undefined && matches.count !== edit.expectedMatches) throw fault('MATCH_COUNT_MISMATCH', '实际匹配数与 expectedMatches 不一致', { matched: matches.count });
165
+ const chosen = matches.selected;
166
+ let updated = original;
167
+ for (const match of chosen.reverse()) {
168
+ const start = map ? map(match.index) : match.index;
169
+ const end = map ? map(match.index + match[0].length) : match.index + match[0].length;
170
+ updated = updated.slice(0, start) + replacement + updated.slice(end);
171
+ }
172
+ return { updated, matched: matches.count, replaced: chosen.length, eolAdapted: !!map };
173
+ }
174
+
175
+ module.exports = { splitPatterns, escapeRegex, globToRegex, globMatcher, applyLiteral };
@@ -0,0 +1,43 @@
1
+ /** 将用户正则隔离在可终止线程,主线程保持 MCP 心跳响应。 */
2
+ const { parentPort } = require('node:worker_threads');
3
+
4
+ /** 执行有限输出的匹配或编辑,超时由父线程终止整个 worker。 */
5
+ function execute(task) {
6
+ const regex = new RegExp(task.pattern, 'gm' + (task.ignoreCase ? 'i' : ''));
7
+ if (task.operation === 'edit') {
8
+ let count = 0;
9
+ for (const ignored of task.text.matchAll(regex)) {
10
+ void ignored;
11
+ if (++count > 100000) throw new Error('匹配数超过上限');
12
+ }
13
+ const replacer = task.replaceAll ? regex : new RegExp(task.pattern, 'm' + (task.ignoreCase ? 'i' : ''));
14
+ return { updated: task.text.replace(replacer, task.replacement), matched: count, replaced: task.replaceAll ? count : Math.min(count, 1) };
15
+ }
16
+ const results = [];
17
+ let chars = 0;
18
+ let truncated = false;
19
+ const lines = task.text.split(/\r\n|\r|\n/);
20
+ for (let i = 0; i < lines.length; i++) {
21
+ regex.lastIndex = 0;
22
+ for (const match of lines[i].matchAll(regex)) {
23
+ const value = task.onlyMatching ? match[0] : lines[i];
24
+ const context = task.contextLines ? {
25
+ before: lines.slice(Math.max(0, i - task.contextLines), i),
26
+ after: lines.slice(i + 1, i + 1 + task.contextLines),
27
+ } : undefined;
28
+ const hit = { line: i + 1, text: value, ...(context ? { context } : {}) };
29
+ const cost = JSON.stringify(hit).length + 32;
30
+ if (results.length >= task.limit || chars + cost > task.maxChars) { truncated = true; break; }
31
+ results.push(hit);
32
+ chars += cost;
33
+ if (!task.onlyMatching) break;
34
+ }
35
+ if (truncated) break;
36
+ }
37
+ return { results, truncated };
38
+ }
39
+
40
+ parentPort.on('message', message => {
41
+ try { parentPort.postMessage({ id: message.id, result: execute(message.task) }); }
42
+ catch (error) { parentPort.postMessage({ id: message.id, error: error.message }); }
43
+ });
package/lib/regex.js ADDED
@@ -0,0 +1,55 @@
1
+ /** 复用正则 worker;每个调用独立截止,超时后重建线程。 */
2
+ const { Worker } = require('node:worker_threads');
3
+ const path = require('node:path');
4
+ const { fault } = require('./text');
5
+ let worker = null;
6
+ let serial = 0;
7
+ const pending = new Map();
8
+
9
+ /** 终止当前 worker,拒绝所有受该线程故障影响的请求。 */
10
+ function stop(error = fault('CANCELLED', '正则任务已取消')) {
11
+ const previous = worker;
12
+ worker = null;
13
+ for (const job of pending.values()) { clearTimeout(job.timer); job.cleanup(); job.reject(error); }
14
+ pending.clear();
15
+ if (previous) previous.terminate().catch(() => {});
16
+ }
17
+
18
+ /** 创建工作线程并分派结果,不让单次回溯阻塞服务主线程。 */
19
+ function ensureWorker() {
20
+ if (worker) return worker;
21
+ const current = new Worker(path.join(__dirname, 'regex-worker.js'), { resourceLimits: { maxOldGenerationSizeMb: 128 } });
22
+ worker = current;
23
+ current.on('message', message => {
24
+ const job = pending.get(message.id);
25
+ if (!job) return;
26
+ pending.delete(message.id);
27
+ clearTimeout(job.timer);
28
+ job.cleanup();
29
+ if (message.error) job.reject(fault('INVALID_REGEX', message.error));
30
+ else job.resolve(message.result);
31
+ if (!pending.size) current.unref();
32
+ });
33
+ current.on('error', error => { if (worker === current) stop(fault('REGEX_WORKER_ERROR', error.message)); });
34
+ current.on('exit', code => { if (worker === current) stop(fault('REGEX_WORKER_EXIT', '正则线程退出: ' + code)); });
35
+ return current;
36
+ }
37
+
38
+ /** 运行有硬超时和队列上限的正则任务。 */
39
+ function runRegex(task, options = {}) {
40
+ if (task.pattern.length > 4096) return Promise.reject(fault('INVALID_REGEX', '正则超过 4096 字符'));
41
+ if (pending.size >= 16) return Promise.reject(fault('BUSY', '正则队列已满,请缩小搜索范围'));
42
+ if (options.signal?.aborted) return Promise.reject(fault('CANCELLED', '操作已取消'));
43
+ return new Promise((resolve, reject) => {
44
+ const current = ensureWorker();
45
+ current.ref();
46
+ const id = ++serial;
47
+ const abort = () => stop(fault('CANCELLED', '正则任务已取消'));
48
+ const timer = setTimeout(() => stop(fault('REGEX_TIMEOUT', '正则计算超过预算;请简化正则或使用字面量模式')), options.timeoutMs ?? 1000);
49
+ options.signal?.addEventListener('abort', abort, { once: true });
50
+ pending.set(id, { resolve, reject, timer, cleanup: () => options.signal?.removeEventListener('abort', abort) });
51
+ current.postMessage({ id, task });
52
+ });
53
+ }
54
+
55
+ module.exports = { runRegex, stop };
package/lib/server.js ADDED
@@ -0,0 +1,314 @@
1
+ /** MCP 工具适配层:保留工具名,统一参数预算与结构化结果。 */
2
+ const fs = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const { performance } = require('node:perf_hooks');
5
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
6
+ const { z } = require('zod');
7
+ const pkg = require('../package.json');
8
+ const text = require('./text');
9
+ const { globMatcher, applyLiteral, escapeRegex } = require('./patterns');
10
+ const { runRegex } = require('./regex');
11
+ const { createEncryption } = require('./encryption');
12
+ const { createFiles, statMaybe } = require('./files');
13
+ const IGNORE = new Set(['node_modules', '.git', 'target', 'build', 'dist', '.idea', '.vscode', '.svn', 'bin', 'obj', 'out', 'vendor']);
14
+ const pathSchema = z.string().min(1).max(32768).describe('路径;相对路径以 MCP_BASE_DIR 或服务启动目录为基准');
15
+ const hashSchema = z.string().regex(/^[a-f0-9]{64}$/i);
16
+ const timeoutSchema = z.number().int().min(100).max(60000).optional();
17
+ const policySchema = z.enum(['auto', 'preserve', 'plaintext']).optional();
18
+ const patternsSchema = z.union([z.string().max(4096), z.array(z.string().max(1024)).max(100)]).optional();
19
+ const outputSchema = z.object({ ok: z.boolean(), code: z.string(), changed: z.boolean(), data: z.record(z.string(), z.unknown()), warnings: z.array(z.string()) });
20
+
21
+ /** 同时生成一致的结构化结果与无装饰前缀的文本。 */
22
+ function result(message, data = {}, { ok = true, code = 'OK', warnings = [] } = {}) {
23
+ const structuredContent = { ok, code, changed: !!data.changed, data, warnings };
24
+ let body = message + (warnings.length ? '\n注意:' + warnings.join(';') : '');
25
+ if (body.length > text.READ_LIMIT + 4096) body = body.slice(0, text.safeEnd(body, text.READ_LIMIT)) + '\n输出已截断,请分页';
26
+ return { content: [{ type: 'text', text: body }], structuredContent, ...(ok ? {} : { isError: true }) };
27
+ }
28
+
29
+ /** 返回有界的前后差异片段,避免预览复制整个大文件。 */
30
+ function preview(before, after) {
31
+ let start = 0;
32
+ while (start < before.length && start < after.length && before[start] === after[start]) start++;
33
+ let tail = 0;
34
+ while (tail < before.length - start && tail < after.length - start && before[before.length - 1 - tail] === after[after.length - 1 - tail]) tail++;
35
+ const from = Math.max(0, start - 100);
36
+ return { offset: from, before: before.slice(from, Math.min(before.length - tail + 100, from + 4000)), after: after.slice(from, Math.min(after.length - tail + 100, from + 4000)), abbreviated: Math.max(before.length, after.length) - tail - from > 4000 };
37
+ }
38
+
39
+ /** 创建服务并允许测试注入独立加密适配器。 */
40
+ function createServer(options = {}) {
41
+ const server = new McpServer({ name: 'read-file-server', version: pkg.version });
42
+ const encryption = options.encryption || createEncryption(options);
43
+ const files = createFiles(encryption, options);
44
+ const handlers = {};
45
+
46
+ /** 包装工具的截止时间、取消、只读限制和可恢复错误字段。 */
47
+ function register(name, description, schema, readOnly, action) {
48
+ const handler = async (args, extra = {}) => {
49
+ const context = { signal: extra.signal, deadline: performance.now() + (args.timeoutMs ?? 15000) };
50
+ try {
51
+ if (files.readOnly && !readOnly && !args.dryRun) throw text.fault('READ_ONLY', '服务以只读模式运行');
52
+ return await action(args, context);
53
+ } catch (error) {
54
+ return result('操作失败:' + error.message, { changed: !!error.changed,
55
+ ...(error.recoveryPath ? { recoveryPath: error.recoveryPath, rollbackError: error.rollbackError } : {}),
56
+ ...(error.partial ? { partial: error.partial, sourceRetained: error.sourceRetained } : {}),
57
+ }, { ok: false, code: error.code || 'INTERNAL_ERROR' });
58
+ }
59
+ };
60
+ handlers[name] = handler;
61
+ server.registerTool(name, { description, inputSchema: schema, outputSchema, annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, openWorldHint: false } }, handler);
62
+ }
63
+
64
+ register('read_file', '严格读取UTF8;最多40万字符,完整读取时返回hash,截断时返回nextOffset。', { path: pathSchema }, true, async (args, context) => {
65
+ const data = await text.readText(await files.resolve(args.path), context);
66
+ if (!data.truncated) data.hash = text.payloadFingerprint((data.hasBom ? '\uFEFF' : '') + data.content).hash;
67
+ return result(data.content + (data.truncated ? '\n文件已截断,请用read_file_partial和nextOffset继续读取' : ''), data);
68
+ });
69
+ register('read_files', '批量严格读取UTF8,最多100个文件,总内容预算40万字符,逐项返回状态。', { paths: z.union([z.array(pathSchema).min(1).max(100), z.string().min(1).max(32768)]) }, true, async (args, context) => {
70
+ const paths = Array.isArray(args.paths) ? args.paths : args.paths.split(',').map(p => p.trim()).filter(Boolean);
71
+ if (!paths.length || paths.length > 100) throw text.fault('INVALID_PATHS', '文件列表需要1至100项');
72
+ let remaining = text.READ_LIMIT;
73
+ const entries = [];
74
+ for (const value of paths) {
75
+ text.checkBudget(context.signal, context.deadline);
76
+ if (remaining <= 0) { entries.push({ path: value, code: 'BUDGET_EXHAUSTED' }); continue; }
77
+ try {
78
+ const data = await text.readText(await files.resolve(value), { ...context, limit: remaining });
79
+ remaining -= data.content.length + value.length + 50;
80
+ entries.push({ path: value, ok: true, ...data });
81
+ } catch (error) { entries.push({ path: value, ok: false, code: error.code, error: error.message }); }
82
+ }
83
+ const ok = entries.some(item => item.ok);
84
+ return result(entries.map(item => '文件: ' + item.path + '\n' + (item.content ?? item.error ?? item.code)).join('\n\n'), { entries }, { ok, code: ok ? 'OK' : 'READ_FAILED' });
85
+ });
86
+ register('read_file_partial', '字符/行分页。offset按去BOM后的UTF16单元计算,请使用nextOffset;行模式流式读取并返回nextLine。', {
87
+ path: pathSchema, mode: z.enum(['chars', 'lines']), charCount: z.number().int().min(1).max(text.READ_LIMIT).optional(), offset: z.number().int().min(0).max(text.SCAN_BYTES).optional(), startLine: z.number().int().min(1).max(10000000).optional(), endLine: z.number().int().min(1).max(10000000).optional(), timeoutMs: timeoutSchema,
88
+ }, true, async (args, context) => {
89
+ if (args.mode === 'chars' && args.charCount === undefined) throw text.fault('MISSING_PARAMETER', 'chars模式必须提供charCount');
90
+ if (args.mode === 'lines' && args.startLine === undefined) throw text.fault('MISSING_PARAMETER', 'lines模式必须提供startLine');
91
+ if (args.mode === 'lines' && (args.endLine ?? args.startLine) < args.startLine) throw text.fault('INVALID_RANGE', 'endLine不能小于startLine');
92
+ const file = await files.resolve(args.path);
93
+ const data = args.mode === 'chars' ? await text.readText(file, { ...context, limit: args.charCount, offset: args.offset ?? 0 }) : await text.readLines(file, args.startLine, args.endLine ?? args.startLine, context);
94
+ return result(args.mode === 'chars' ? data.content : data.lines.map(line => String(line.line).padStart(6) + ' | ' + line.text).join('\n'), data);
95
+ });
96
+ register('write_file', '写入或追加UTF8,暂存校验后提交,失败保留原文件;支持BOM/EOL、hash冲突检查、禁止覆盖和显式策略。', {
97
+ path: pathSchema, content: z.string().max(text.EDIT_BYTES), mode: z.enum(['overwrite', 'append']).optional(), eol: z.enum(['auto', 'lf', 'crlf']).optional(), expectedHash: hashSchema.optional(), overwrite: z.boolean().optional(), writePolicy: policySchema, timeoutMs: timeoutSchema,
98
+ }, false, async (args, context) => {
99
+ const file = await files.resolve(args.path, { write: true });
100
+ return files.locked([file], async () => {
101
+ const before = await files.previous(file, context);
102
+ const old = before ? await text.readFull(file, context) : { content: '', hasBom: false };
103
+ const eol = args.eol === 'crlf' ? '\r\n' : args.eol === 'lf' ? '\n' : text.detectEol(old.content);
104
+ let addition = text.normalizeEol(args.content, eol);
105
+ if (addition.startsWith('\uFEFF')) addition = addition.slice(1);
106
+ const payload = (old.hasBom || (!before && args.content.startsWith('\uFEFF')) ? '\uFEFF' : '') + (args.mode === 'append' ? old.content : '') + addition;
107
+ if (Buffer.byteLength(payload) > text.EDIT_BYTES) throw text.fault('FILE_TOO_LARGE', '文本写入/追加超过16MB预算');
108
+ const data = await files.write(file, payload, { ...context, expectedHash: args.expectedHash?.toLowerCase() ?? before?.hash ?? null, overwrite: args.overwrite, writePolicy: args.writePolicy });
109
+ return result('写入成功: ' + file, data, { warnings: data.warnings || [] });
110
+ }, context);
111
+ });
112
+
113
+ const editShape = { oldString: z.string().min(1).max(text.EDIT_BYTES), newString: z.string().max(text.EDIT_BYTES), replaceAll: z.boolean().optional(), expectedMatches: z.number().int().min(1).max(100000).optional() };
114
+ register('edit_file', '单次或批量编辑互斥;全部匹配成功后暂存提交。支持dryRun预览、expectedHash/expectedMatches、换行兼容和限时正则。', {
115
+ path: pathSchema, oldString: editShape.oldString.optional(), newString: editShape.newString.optional(), edits: z.array(z.object(editShape)).min(1).max(200).optional(), replaceAll: z.boolean().optional(), useRegex: z.boolean().optional(), ignoreCase: z.boolean().optional(), expectedMatches: editShape.expectedMatches, expectedHash: hashSchema.optional(), dryRun: z.boolean().optional(), writePolicy: policySchema, timeoutMs: timeoutSchema,
116
+ }, false, async (args, context) => {
117
+ if (args.edits && ['oldString', 'newString', 'useRegex', 'replaceAll', 'expectedMatches'].some(key => args[key] !== undefined)) throw text.fault('AMBIGUOUS_EDIT', 'edits与单次编辑参数不能同时提供');
118
+ if (!args.edits && (args.oldString === undefined || args.newString === undefined)) throw text.fault('MISSING_PARAMETER', '请提供oldString/newString或edits');
119
+ const file = await files.resolve(args.path, { write: !args.dryRun });
120
+ return files.locked([file], async () => {
121
+ const before = await files.previous(file, context);
122
+ if (!before) throw text.fault('ENOENT', '文件不存在');
123
+ if (args.expectedHash && args.expectedHash.toLowerCase() !== before.hash) throw text.fault('CONFLICT', 'expectedHash与当前文件不一致');
124
+ const original = await text.readFull(file, context);
125
+ let updated = original.content;
126
+ let replaced = 0;
127
+ let matched = 0;
128
+ for (const item of args.edits || [args]) {
129
+ text.checkBudget(context.signal, context.deadline);
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 }, { ...context, timeoutMs: Math.max(1, Math.min(1000, context.deadline - performance.now())) }) : applyLiteral(updated, item, args.ignoreCase);
131
+ if (!change.matched) throw text.fault('NO_MATCH', '未找到匹配内容,文件未修改');
132
+ if (item.expectedMatches !== undefined && item.expectedMatches !== change.matched) throw text.fault('MATCH_COUNT_MISMATCH', '实际匹配数与expectedMatches不一致');
133
+ updated = change.updated;
134
+ if (Buffer.byteLength(updated) > text.EDIT_BYTES) throw text.fault('FILE_TOO_LARGE', '编辑结果超过16MB预算');
135
+ replaced += change.replaced;
136
+ matched += change.matched;
137
+ }
138
+ const payload = (original.hasBom ? '\uFEFF' : '') + updated;
139
+ const details = { matched, replaced, originalHash: before.hash, proposedHash: text.payloadFingerprint(payload).hash, preview: preview(original.content, updated) };
140
+ if (args.dryRun) return result('编辑预览完成,文件未修改', { changed: false, dryRun: true, ...details });
141
+ const data = await files.write(file, payload, { ...context, expectedHash: before.hash, writePolicy: args.writePolicy });
142
+ return result('编辑成功: ' + file + '\n实际替换 ' + replaced + ' 处,共匹配 ' + matched + ' 处', { ...data, ...details }, { warnings: data.warnings || [] });
143
+ }, context);
144
+ });
145
+
146
+ /** 遍历目录并记录访问错误;不跟随符号链接,内部暂存文件永远不作为结果。 */
147
+ async function* walk(root, args, errors, context) {
148
+ const st = await fs.lstat(root);
149
+ if (st.isFile()) { yield { file: root, relative: path.basename(root), type: 'file' }; return; }
150
+ if (!st.isDirectory()) throw text.fault('NOT_DIRECTORY', '根路径不是普通文件或目录');
151
+ const excluded = new Set(args.useDefaultIgnore === false ? [] : IGNORE);
152
+ for (const entry of args.exclude || []) excluded.add(entry);
153
+ let visited = 0;
154
+ /** 每一层检查深度、扫描数量、取消和时间预算。 */
155
+ async function* visit(dir, depth) {
156
+ if (depth > 128) throw text.fault('DEPTH_LIMIT', '目录深度超过128层');
157
+ let handle;
158
+ try { handle = await fs.opendir(dir); }
159
+ catch (error) { if (errors.length < 100) errors.push({ path: dir, code: error.code }); return; }
160
+ for await (const entry of handle) {
161
+ text.checkBudget(context.signal, context.deadline);
162
+ if (++visited > 100000) throw text.fault('SCAN_LIMIT', '扫描超过十万项,请缩小范围');
163
+ if (entry.name.startsWith('.mcp-') || (!args.showHidden && entry.name.startsWith('.'))) continue;
164
+ if (entry.isDirectory() && excluded.has(entry.name)) continue;
165
+ const file = path.join(dir, entry.name);
166
+ const relative = path.relative(root, file).replace(/\\/g, '/');
167
+ if (entry.isSymbolicLink()) { yield { file, relative, type: 'symlink' }; continue; }
168
+ yield { file, relative, type: entry.isDirectory() ? 'directory' : 'file' };
169
+ if (entry.isDirectory()) yield* visit(file, depth + 1);
170
+ }
171
+ }
172
+ yield* visit(root, 0);
173
+ }
174
+ const scanShape = { path: pathSchema, maxResults: z.number().int().min(1).max(2000).optional(), showHidden: z.boolean().optional(), useDefaultIgnore: z.boolean().optional(), exclude: patternsSchema, timeoutMs: timeoutSchema };
175
+ register('search_files', '按行搜索,支持literal/regex、include数组与花括号、隐藏项开关。单文件5MB,总输出40万字符,错误明确报告;正则限时执行。', {
176
+ ...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
+ }, true, async (args, context) => {
178
+ const root = await files.resolve(args.path);
179
+ const include = globMatcher(args.include);
180
+ const errors = [];
181
+ const skipped = { binary: 0, large: 0, unreadable: 0 };
182
+ const results = [];
183
+ const limit = args.maxResults ?? 200;
184
+ let budget = text.READ_LIMIT;
185
+ let scanned = 0;
186
+ let truncated = false;
187
+ const exclude = Array.isArray(args.exclude) ? args.exclude : (args.exclude || '').split(',').filter(Boolean);
188
+ for await (const entry of walk(root, { ...args, exclude }, errors, context)) {
189
+ if (entry.type !== 'file' || !include(entry.relative)) continue;
190
+ if (results.length >= limit || budget <= 0) { truncated = true; break; }
191
+ let content;
192
+ try {
193
+ const st = await fs.stat(entry.file);
194
+ if (st.size > 5 * 1024 * 1024) { skipped.large++; continue; }
195
+ content = (await text.readText(entry.file, { ...context, limit: Infinity, maxBytes: 5 * 1024 * 1024 })).content;
196
+ } catch (error) {
197
+ if (['CANCELLED', 'TIMEOUT'].includes(error.code)) throw error;
198
+ if (error.code === 'FILE_TOO_LARGE') skipped.large++;
199
+ else if (['BINARY_FILE', 'INVALID_UTF8', 'UNSUPPORTED_ENCODING'].includes(error.code)) skipped.binary++;
200
+ else skipped.unreadable++;
201
+ if (errors.length < 100) errors.push({ path: entry.file, code: error.code });
202
+ continue;
203
+ }
204
+ scanned++;
205
+ const found = await runRegex({ operation: 'search', text: content, pattern: args.mode === 'literal' ? escapeRegex(args.pattern) : args.pattern, ignoreCase: args.ignoreCase, onlyMatching: args.onlyMatching, limit: limit - results.length, maxChars: budget, contextLines: args.contextLines ?? 0 }, { ...context, timeoutMs: Math.max(1, Math.min(1000, context.deadline - performance.now())) });
206
+ for (const hit of found.results) {
207
+ const cost = entry.file.length + JSON.stringify(hit).length + 64;
208
+ if (cost > budget) { truncated = true; break; }
209
+ results.push({ path: entry.file, ...hit });
210
+ budget -= cost;
211
+ }
212
+ if (found.truncated || truncated) { truncated = true; break; }
213
+ }
214
+ const warnings = errors.length ? ['部分文件/目录未能扫描,详见errors和skipped;无匹配不代表这些文件没有内容'] : [];
215
+ 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
+ });
217
+ register('find_files', '按glob递归查找,支持花括号、字面括号路径、隐藏项及默认忽略开关。', { ...scanShape, pattern: z.string().min(1).max(1024) }, true, async (args, context) => {
218
+ const root = await files.resolve(args.path);
219
+ const matches = globMatcher([args.pattern]);
220
+ const entries = [];
221
+ const errors = [];
222
+ let truncated = false;
223
+ let budget = text.READ_LIMIT;
224
+ const exclude = Array.isArray(args.exclude) ? args.exclude : (args.exclude || '').split(',').filter(Boolean);
225
+ for await (const entry of walk(root, { ...args, exclude }, errors, context)) {
226
+ if (!matches(entry.relative)) continue;
227
+ budget -= entry.file.length + entry.relative.length + 64;
228
+ if (entries.length >= (args.maxResults ?? 500) || budget < 0) { truncated = true; break; }
229
+ entries.push(entry);
230
+ }
231
+ 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
+ });
233
+ 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) => {
234
+ const root = await files.resolve(args.path);
235
+ const entries = [];
236
+ let seen = 0;
237
+ let nextOffset = null;
238
+ const handle = await fs.opendir(root);
239
+ for await (const entry of handle) {
240
+ text.checkBudget(context.signal, context.deadline);
241
+ if (!args.showHidden && entry.name.startsWith('.')) continue;
242
+ if (seen++ < (args.offset ?? 0)) continue;
243
+ if (entries.length >= (args.maxResults ?? 500)) { nextOffset = seen - 1; break; }
244
+ try {
245
+ const st = await fs.lstat(path.join(root, entry.name));
246
+ entries.push({ name: entry.name, type: st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'directory' : 'file', sizeOnDisk: st.size, modifiedTime: st.mtime.toISOString() });
247
+ } catch (error) { entries.push({ name: entry.name, error: error.code }); }
248
+ }
249
+ return result(entries.map(entry => (entry.type || 'ERROR').toUpperCase() + ' ' + entry.name).join('\n') || '空目录', { entries, nextOffset, truncated: nextOffset !== null });
250
+ });
251
+ register('file_info', '查询大小/时间/链接;流式计算可读字节数及hash,不经文本解码。calculateHash=false可只查元数据。', { path: pathSchema, calculateHash: z.boolean().optional(), timeoutMs: timeoutSchema }, true, async (args, context) => {
252
+ const file = await files.resolve(args.path, { leaf: true });
253
+ const st = await statMaybe(file);
254
+ if (!st) return result('路径不存在', { path: file, exists: false });
255
+ const data = { path: file, exists: true, type: st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'directory' : 'file', sizeOnDisk: st.size, modifiedTime: st.mtime.toISOString(), createdTime: st.birthtime.toISOString() };
256
+ if (st.isSymbolicLink()) data.symlinkTarget = await fs.readlink(file);
257
+ 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
+ return result(JSON.stringify(data, null, 2), data);
259
+ });
260
+ register('create_directory', '递归创建目录,受只读模式与allowedRoots约束。', { path: pathSchema }, false, async args => {
261
+ const dir = await files.resolve(args.path, { write: true });
262
+ const existed = !!(await statMaybe(dir));
263
+ await fs.mkdir(dir, { recursive: true });
264
+ return result('目录已创建: ' + dir, { path: dir, changed: !existed });
265
+ });
266
+ const transferShape = { source: pathSchema, destination: pathSchema, overwrite: z.boolean().optional(), writePolicy: policySchema, timeoutMs: timeoutSchema };
267
+ register('copy_path', '逐文件复制和验证。目标已有目录时放入其下;失败返回部分目标并保留源,不自动跟随链接。', transferShape, false, async (args, context) => {
268
+ const data = await files.transfer(args.source, args.destination, false, { ...context, overwrite: args.overwrite, writePolicy: args.writePolicy });
269
+ return result('复制完成: ' + data.destination, data, { warnings: data.warnings || [] });
270
+ });
271
+ register('move_path', '复制验证后再删源,同路径不操作;同盘/跨盘使用相同校验流程。', transferShape, false, async (args, context) => {
272
+ const data = await files.transfer(args.source, args.destination, true, { ...context, overwrite: args.overwrite, writePolicy: args.writePolicy });
273
+ return result('移动完成: ' + data.destination, data, { warnings: data.warnings || [] });
274
+ });
275
+ register('remove_path', '删除文件/目录或链接。recursive=false仅删空目录,dryRun仅预览;拒绝删除工作根/盘符根。', { path: pathSchema, recursive: z.boolean().optional(), dryRun: z.boolean().optional() }, false, async (args, context) => {
276
+ const data = await files.remove(args.path, { ...context, recursive: args.recursive, dryRun: args.dryRun });
277
+ return result(args.dryRun ? '删除预览,未修改文件' : '删除完成', data);
278
+ });
279
+ register('check_status', '心跳不触发探测;可选文件前缀检查,只在expectedHash可信对照一致时确认内容,不凭能读取推断解密正常。', { path: pathSchema.optional(), expectedHash: hashSchema.optional() }, true, async (args, context) => {
280
+ const data = { version: pkg.version, node: process.version, baseDir: files.baseDir, readOnly: files.readOnly, decryptionVerified: false };
281
+ if (!args.path) return result('read-file-server 运行中;本次未验证解密能力', data);
282
+ const file = await files.resolve(args.path);
283
+ const sample = await text.readText(file, { ...context, limit: 200 });
284
+ if (sample.content.startsWith('%TSD')) throw text.fault('CIPHERTEXT_DETECTED', '检测到TSD密文头,不能认定解密正常');
285
+ data.readable = true;
286
+ data.validUtf8Sample = true;
287
+ if (args.expectedHash) {
288
+ const fp = await text.fingerprint(file, context);
289
+ if (fp.hash !== args.expectedHash.toLowerCase()) throw text.fault('HASH_MISMATCH', '内容与可信hash不一致');
290
+ data.decryptionVerified = true;
291
+ }
292
+ return result(args.expectedHash ? '读取内容与可信明文hash一致' : '文件前缀可读取为UTF8;尚未证明解密成功', data);
293
+ });
294
+ register('encryption_profile', '查看自动探测和独立手工策略;首次调用会创建隔离探测样本与缓存。', { timeoutMs: timeoutSchema }, false, async (_args, context) => {
295
+ const data = await encryption.summary(context);
296
+ return result(JSON.stringify(data, null, 2), data);
297
+ });
298
+ register('refresh_profile', '刷新自动探测缓存,保留手工protected/unsafe策略。', { timeoutMs: timeoutSchema }, false, async (_args, context) => {
299
+ await encryption.refresh(context);
300
+ const data = await encryption.summary(context);
301
+ return result('自动探测已刷新,手工策略保持不变\n' + JSON.stringify(data, null, 2), data);
302
+ });
303
+ register('mark_extension', '独立保存策略:protected保留受控写入,unsafe必须验证明文,clear清除手工覆盖;刷新与TTL不丢失标注。', { extension: z.string().min(1).max(33), category: z.enum(['protected', 'unsafe', 'clear']) }, false, async args => {
304
+ const data = await encryption.mark(args.extension, args.category);
305
+ return result('策略已更新: ' + data.extension + ' = ' + data.category, { ...data, changed: true });
306
+ });
307
+ register('inspect_write_strategy', '检查有效写入策略;在已存在目标目录创建并清理随机探测样本,不修改目标文件。', { path: pathSchema, writePolicy: policySchema, timeoutMs: timeoutSchema }, false, async (args, context) => {
308
+ const file = await files.resolve(args.path);
309
+ const data = await encryption.strategy(file, args.writePolicy, context);
310
+ return result(JSON.stringify(data, null, 2), data);
311
+ });
312
+ return { server, handlers, encryption, files };
313
+ }
314
+ module.exports = { createServer, result, preview };