@mario.andreschak/mcp-filesystem 3.39.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/dist/tools.js ADDED
@@ -0,0 +1,1229 @@
1
+ /**
2
+ * Built-in `filesystem` MCP server (issue #170).
3
+ *
4
+ * Cross-platform (Windows/macOS/Linux) filesystem access with:
5
+ * - structured JSON outputs (MCP `structuredContent` + a text fallback block),
6
+ * - line-targeted reads AND writes/edits,
7
+ * - literal find/replace edits AND real unified-diff (`@@`) patch apply,
8
+ * - directory listing/tree and name/content search.
9
+ *
10
+ * Relative paths resolve against the FLUJO data directory; absolute paths are
11
+ * honored as-is (same host-access posture as the legacy `terminal` tool). Two
12
+ * layers of confinement can narrow that:
13
+ * - the FLUJO_FS_ROOTS env (path-list separated by the OS path delimiter) acts
14
+ * as a HARD CEILING an operator sets — no path may ever escape it, and
15
+ * - user-configured roots persisted via the MCP manager UI (issue #170), which
16
+ * may only narrow WITHIN the env ceiling (never widen it).
17
+ * When neither is set the server has full host access. An optional experimental
18
+ * deny layer (issue #260, see protectedPaths.ts) can additionally block a
19
+ * hardcoded set of sensitive home locations regardless of configured roots. It
20
+ * is disabled by default.
21
+ *
22
+ * Every tool returns a machine-readable JSON envelope both as MCP
23
+ * `structuredContent` and as a single text content block (for backward-compat
24
+ * clients); errors are returned as `isError: true` results rather than thrown.
25
+ */
26
+ import path from 'node:path';
27
+ import { promises as fs, createReadStream } from 'node:fs';
28
+ import { createHash } from 'node:crypto';
29
+ import readline from 'node:readline';
30
+ import { ALLOW_PROTECTED_PATHS_ENV, createLogger, getDataDir, isInside, isProtected, isProtectedPathsEnabled, loadEffectiveRoots, } from './shared.js';
31
+ import { recordTouchedFile } from './resources.js';
32
+ const FILESYSTEM_SERVER_NAME = 'filesystem';
33
+ const log = createLogger('backend/services/mcp/internal/filesystemTools');
34
+ /** Output cap so a huge file/listing can't flood the model's context. */
35
+ const MAX_READ_CHARS = 200_000;
36
+ /** #316: bound batch fan-out and its cumulative serialized response size. */
37
+ const MAX_BATCH_READ_FILES = 25;
38
+ const MAX_BATCH_READ_CHARS = 1_000_000;
39
+ const MAX_SEARCH_RESULTS = 1_000;
40
+ const DEFAULT_TREE_DEPTH = 3;
41
+ const MAX_TREE_DEPTH = 10;
42
+ const MAX_TREE_ENTRIES = 5_000;
43
+ // Advertise the real side-effect profile to MCP clients. Codex in particular
44
+ // uses these hints to distinguish safe discovery/reads from mutations instead
45
+ // of treating every FLUJO filesystem operation as an unknown-risk action.
46
+ const READ_ONLY_ANNOTATIONS = {
47
+ readOnlyHint: true,
48
+ destructiveHint: false,
49
+ idempotentHint: true,
50
+ openWorldHint: false,
51
+ };
52
+ const WRITE_ANNOTATIONS = {
53
+ readOnlyHint: false,
54
+ destructiveHint: false,
55
+ openWorldHint: false,
56
+ };
57
+ const DESTRUCTIVE_WRITE_ANNOTATIONS = {
58
+ readOnlyHint: false,
59
+ destructiveHint: true,
60
+ openWorldHint: false,
61
+ };
62
+ /**
63
+ * #287: files at/above this size may not be read whole without an explicit
64
+ * `pattern` (grep first, then targeted line reads) — or a `*` opt-in. Keeps a
65
+ * large file from silently flooding the model's context on a bare read.
66
+ */
67
+ const LARGE_FILE_BYTES = 100_000;
68
+ /** #287: skip files bigger than this during content search (perf/binary guard). */
69
+ const SEARCH_MAX_FILE_BYTES = 5_000_000;
70
+ /** #287: how many files to scan concurrently during a content search. */
71
+ const SEARCH_CONCURRENCY = 16;
72
+ /** #287: context lines shown around each `read_file` pattern match. */
73
+ const READ_PATTERN_CONTEXT = 2;
74
+ /** #287: cap on matches returned by a single `read_file` pattern grep. */
75
+ const MAX_READ_PATTERN_MATCHES = 200;
76
+ /** Return a structured payload as BOTH MCP structuredContent and a text fallback. */
77
+ function dualResult(payload) {
78
+ return {
79
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
80
+ structuredContent: payload,
81
+ };
82
+ }
83
+ /** Return a plain string/error message (no structuredContent). */
84
+ function textResult(message, isError = false) {
85
+ return {
86
+ content: [{ type: 'text', text: message }],
87
+ ...(isError ? { isError: true } : {}),
88
+ };
89
+ }
90
+ /** Return an error envelope (JSON text + isError). */
91
+ function errorResult(message) {
92
+ return {
93
+ content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
94
+ isError: true,
95
+ };
96
+ }
97
+ /**
98
+ * Resolve a user-supplied path against the data dir (for relative paths) and
99
+ * enforce the effective confinement roots. An empty roots array blocks all access.
100
+ * Throws on a confinement violation so callers surface a precise error.
101
+ */
102
+ async function resolvePath(input, roots) {
103
+ const raw = typeof input === 'string' ? input.trim() : '';
104
+ if (!raw)
105
+ throw new Error('Provide "path".');
106
+ const dataDir = getDataDir();
107
+ const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(dataDir, raw);
108
+ // Optional defense-in-depth layer (issue #260). Configured roots win by
109
+ // default; users can opt into this stricter policy under Experimental Features.
110
+ if (await isProtectedPathsEnabled()) {
111
+ const prot = isProtected(resolved);
112
+ if (prot.denied) {
113
+ throw new Error(`Path "${resolved}" is within a protected location (${prot.matchedRoot}) and is blocked by the FLUJO built-in server protected-path policy. ` +
114
+ `Disable "Protect sensitive home-directory paths" in Experimental Features or set ${ALLOW_PROTECTED_PATHS_ENV}=1 to override.`);
115
+ }
116
+ }
117
+ if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
118
+ throw new Error(`Path "${resolved}" is outside the configured filesystem roots.`);
119
+ }
120
+ return resolved;
121
+ }
122
+ export function filesystemToolDefinitions() {
123
+ const pathProp = { type: 'string', description: 'File or directory path. Relative paths resolve against the FLUJO data directory; absolute paths are used as-is.' };
124
+ return [
125
+ {
126
+ name: 'read_file',
127
+ annotations: READ_ONLY_ANNOTATIONS,
128
+ description: 'Read one text file with "path", or up to 25 files in input order with mutually exclusive "paths". Optional "from"/"to" (1-based, inclusive) and "pattern" settings apply to every target. ' +
129
+ `For large files (> ${LARGE_FILE_BYTES / 1000} KB) read WHOLE (no "from"/"to"), a "pattern" is REQUIRED: the server greps the file and returns only matching lines (with a little surrounding context) so you can follow up with targeted "from"/"to" reads. Pass pattern "*" to force-read the entire large file anyway. ` +
130
+ 'Single reads return { path, from, to, totalLines, content, truncated, contentHash, matches? } unchanged. Batch reads return { files }, with one ordered success or { requestedPath, path?, error } record per input; individual failures do not discard successful reads. Batch output is limited to 1,000,000 serialized characters. Pass contentHash back as "expectedHash" on a follow-up edit_file/write_file to guard against the file changing in between (TOCTOU).',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {
134
+ path: pathProp,
135
+ paths: {
136
+ type: 'array',
137
+ minItems: 1,
138
+ maxItems: MAX_BATCH_READ_FILES,
139
+ items: { type: 'string' },
140
+ description: 'File paths to read in input order. Mutually exclusive with "path".',
141
+ },
142
+ from: { type: 'number', description: 'Optional 1-based first line to return (inclusive).' },
143
+ to: { type: 'number', description: 'Optional 1-based last line to return (inclusive).' },
144
+ pattern: { type: 'string', description: 'Case-insensitive substring to grep for. Required to read a large file whole without a "from"/"to" range; matching lines (plus context) are returned instead of the full body. Use "*" to read the whole large file anyway.' },
145
+ },
146
+ },
147
+ outputSchema: {
148
+ type: 'object',
149
+ properties: {
150
+ path: { type: 'string' },
151
+ files: {
152
+ type: 'array',
153
+ description: 'Ordered batch results. Successful entries use the single-file payload; failures include requestedPath and error.',
154
+ items: { type: 'object' },
155
+ },
156
+ from: { type: 'number' },
157
+ to: { type: 'number' },
158
+ totalLines: { type: 'number' },
159
+ truncated: { type: 'boolean' },
160
+ content: { type: 'string' },
161
+ contentHash: { type: 'string', description: 'SHA-256 (hex) of the raw file content. Pass this back as "expectedHash" to edit_file/write_file to guard against the file changing between read and write (TOCTOU).' },
162
+ matches: {
163
+ type: 'array',
164
+ items: {
165
+ type: 'object',
166
+ properties: { line: { type: 'number' }, text: { type: 'string' } },
167
+ required: ['line', 'text'],
168
+ },
169
+ },
170
+ },
171
+ },
172
+ },
173
+ {
174
+ name: 'write_file',
175
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
176
+ description: 'Create or write a text file (parent directories are created). "mode": "overwrite" (default) replaces the whole file, or a line range when "startLine"/"endLine" (1-based inclusive) are given; "append" adds content at the end; "insert" inserts content before "startLine". Optionally pass "expectedHash" (the contentHash from a prior read_file) to reject the write if the file changed on disk since it was read (TOCTOU guard); ignored for a whole-file overwrite. Returns { path, bytesWritten, mode, ... }.',
177
+ // #216: feed the docked diff canvas (ui://devcanvas/diff) so successive
178
+ // writes update one persistent tab. See internal/filesystemResources.ts.
179
+ _meta: { ui: { resourceUri: 'ui://devcanvas/diff' } },
180
+ inputSchema: {
181
+ type: 'object',
182
+ properties: {
183
+ path: pathProp,
184
+ content: { type: 'string', description: 'The content to write/insert/append.' },
185
+ mode: { type: 'string', enum: ['overwrite', 'append', 'insert'], description: 'Write mode (default "overwrite").' },
186
+ startLine: { type: 'number', description: 'For "insert": line to insert before. For "overwrite": first line of the range to replace (1-based).' },
187
+ endLine: { type: 'number', description: 'For "overwrite": last line (inclusive, 1-based) of the range to replace.' },
188
+ expectedHash: { type: 'string', description: 'Optional SHA-256 (contentHash from read_file). For append/insert/range-overwrite modes, the write is rejected if the file no longer hashes to this value (TOCTOU guard).' },
189
+ },
190
+ required: ['path', 'content'],
191
+ },
192
+ outputSchema: {
193
+ type: 'object',
194
+ properties: {
195
+ path: { type: 'string' },
196
+ bytesWritten: { type: 'number' },
197
+ mode: { type: 'string' },
198
+ startLine: { type: 'number' },
199
+ endLine: { type: 'number' },
200
+ linesReplaced: { type: 'number' },
201
+ linesInserted: { type: 'number' },
202
+ },
203
+ required: ['path', 'bytesWritten', 'mode'],
204
+ },
205
+ },
206
+ {
207
+ name: 'edit_file',
208
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
209
+ description: 'Edit a text file two ways (mutually exclusive): (1) "edits": [{ oldText, newText, startLine?, endLine? }] literal find/replace — each replaces the unique occurrence of oldText. startLine/endLine are an optional disambiguation HINT (if exactly one match starts in that range it wins); a wrong/missing range still works as long as oldText is unique in the file. Include enough surrounding context to make oldText unique. Or (2) "diff": a unified diff string ("@@ -a,b +c,d @@" hunks) applied atomically — hunks are relocated to where their context actually matches, so slightly-off @@ line numbers still apply, and CRLF files are handled. Fails with no partial write only when text is missing/ambiguous or a hunk context is not found. Optionally pass "expectedHash" (the contentHash from a prior read_file) to reject the edit if the file changed on disk since it was read (TOCTOU guard). Returns { path, editsApplied|applied, diff:{added,removed} }.',
210
+ // #216: also feed the docked diff canvas so edits show live in the canvas.
211
+ _meta: { ui: { resourceUri: 'ui://devcanvas/diff' } },
212
+ inputSchema: {
213
+ type: 'object',
214
+ properties: {
215
+ path: pathProp,
216
+ edits: {
217
+ type: 'array',
218
+ description: 'List of literal { oldText, newText } edits applied in order (optionally scoped with startLine/endLine).',
219
+ items: {
220
+ type: 'object',
221
+ properties: {
222
+ oldText: { type: 'string', description: 'Exact text to find.' },
223
+ newText: { type: 'string', description: 'Replacement text.' },
224
+ startLine: { type: 'number', description: 'Optional 1-based first line to scope this edit to.' },
225
+ endLine: { type: 'number', description: 'Optional 1-based last line (inclusive) to scope this edit to.' },
226
+ },
227
+ required: ['oldText', 'newText'],
228
+ },
229
+ },
230
+ diff: { type: 'string', description: 'A unified diff to apply atomically. Mutually exclusive with "edits".' },
231
+ startLine: { type: 'number', description: 'Optional default 1-based first line to scope all literal edits to.' },
232
+ endLine: { type: 'number', description: 'Optional default 1-based last line (inclusive) to scope all literal edits to.' },
233
+ expectedHash: { type: 'string', description: 'Optional SHA-256 (contentHash from read_file). If provided and the freshly-read file no longer hashes to it, the edit is rejected with no changes written (TOCTOU guard).' },
234
+ },
235
+ required: ['path'],
236
+ },
237
+ outputSchema: {
238
+ type: 'object',
239
+ properties: {
240
+ path: { type: 'string' },
241
+ mode: { type: 'string' },
242
+ editsApplied: { type: 'number' },
243
+ applied: { type: 'boolean' },
244
+ diff: {
245
+ type: 'object',
246
+ properties: { added: { type: 'number' }, removed: { type: 'number' } },
247
+ required: ['added', 'removed'],
248
+ },
249
+ },
250
+ required: ['path', 'diff'],
251
+ },
252
+ },
253
+ {
254
+ name: 'list_dir',
255
+ annotations: READ_ONLY_ANNOTATIONS,
256
+ description: 'List the entries of a directory. Returns { path, entries: [{ name, type, size }] } where type is "file" | "directory" | "other".',
257
+ inputSchema: {
258
+ type: 'object',
259
+ properties: { path: pathProp },
260
+ required: ['path'],
261
+ },
262
+ outputSchema: {
263
+ type: 'object',
264
+ properties: {
265
+ path: { type: 'string' },
266
+ entries: {
267
+ type: 'array',
268
+ items: {
269
+ type: 'object',
270
+ properties: { name: { type: 'string' }, type: { type: 'string' }, size: { type: 'number' } },
271
+ required: ['name', 'type', 'size'],
272
+ },
273
+ },
274
+ },
275
+ required: ['path', 'entries'],
276
+ },
277
+ },
278
+ {
279
+ name: 'file_browser_ui',
280
+ annotations: READ_ONLY_ANNOTATIONS,
281
+ description: 'Open an interactive file browser in the chat so the USER can pick a file. This returns IMMEDIATELY without a selection — the browser is shown to the user and the file they choose arrives afterwards as a follow-up user message (e.g. "Selected file: <path>"). After calling this tool you MUST stop and wait for that message; do not guess a path or continue until the user has selected.',
282
+ // MCP Apps (#97): this tool exists solely to surface the file-browser app
283
+ // (ui://filesystem/browser). See internal/filesystemResources.ts.
284
+ _meta: { ui: { resourceUri: 'ui://filesystem/browser' } },
285
+ inputSchema: {
286
+ type: 'object',
287
+ properties: {
288
+ path: { ...pathProp, description: 'Optional starting directory for the browser (defaults to the FLUJO data directory).' },
289
+ },
290
+ required: [],
291
+ },
292
+ },
293
+ {
294
+ name: 'dir_tree',
295
+ annotations: READ_ONLY_ANNOTATIONS,
296
+ description: 'Return a recursive, depth-limited directory tree as nested JSON. Returns { path, depth, tree }.',
297
+ inputSchema: {
298
+ type: 'object',
299
+ properties: {
300
+ path: pathProp,
301
+ depth: { type: 'number', description: `Maximum recursion depth (default ${DEFAULT_TREE_DEPTH}, max ${MAX_TREE_DEPTH}).` },
302
+ },
303
+ required: ['path'],
304
+ },
305
+ outputSchema: {
306
+ type: 'object',
307
+ properties: {
308
+ path: { type: 'string' },
309
+ depth: { type: 'number' },
310
+ truncated: { type: 'boolean' },
311
+ tree: { type: 'array' },
312
+ },
313
+ required: ['path', 'depth', 'tree'],
314
+ },
315
+ },
316
+ {
317
+ name: 'search',
318
+ annotations: READ_ONLY_ANNOTATIONS,
319
+ description: 'Search a directory tree. Match file/dir NAMES against "namePattern" (substring, case-insensitive) and/or file CONTENT against "content" (substring, case-insensitive). Content search DOES look inside every text file, returning each matching line with its 1-based line number; likely-binary and very large files are skipped for speed. Returns { matches: [{ path, line?, text? }], truncated }.',
320
+ inputSchema: {
321
+ type: 'object',
322
+ properties: {
323
+ path: pathProp,
324
+ namePattern: { type: 'string', description: 'Optional case-insensitive substring to match against entry names.' },
325
+ content: { type: 'string', description: 'Optional case-insensitive substring to match inside text files.' },
326
+ },
327
+ required: ['path'],
328
+ },
329
+ outputSchema: {
330
+ type: 'object',
331
+ properties: {
332
+ matches: {
333
+ type: 'array',
334
+ items: {
335
+ type: 'object',
336
+ properties: { path: { type: 'string' }, line: { type: 'number' }, text: { type: 'string' } },
337
+ required: ['path'],
338
+ },
339
+ },
340
+ truncated: { type: 'boolean' },
341
+ },
342
+ required: ['matches', 'truncated'],
343
+ },
344
+ },
345
+ {
346
+ name: 'get_file_info',
347
+ annotations: READ_ONLY_ANNOTATIONS,
348
+ description: 'Stat a path. Returns { path, type, size, isFile, isDirectory, createdAt, modifiedAt }.',
349
+ inputSchema: {
350
+ type: 'object',
351
+ properties: { path: pathProp },
352
+ required: ['path'],
353
+ },
354
+ outputSchema: {
355
+ type: 'object',
356
+ properties: {
357
+ path: { type: 'string' },
358
+ type: { type: 'string' },
359
+ size: { type: 'number' },
360
+ isFile: { type: 'boolean' },
361
+ isDirectory: { type: 'boolean' },
362
+ createdAt: { type: 'string' },
363
+ modifiedAt: { type: 'string' },
364
+ },
365
+ required: ['path', 'type', 'size', 'isFile', 'isDirectory', 'createdAt', 'modifiedAt'],
366
+ },
367
+ },
368
+ {
369
+ name: 'create_directory',
370
+ annotations: WRITE_ANNOTATIONS,
371
+ description: 'Create a directory (recursively). Succeeds if it already exists. Returns { path, created }.',
372
+ inputSchema: {
373
+ type: 'object',
374
+ properties: { path: pathProp },
375
+ required: ['path'],
376
+ },
377
+ outputSchema: {
378
+ type: 'object',
379
+ properties: { path: { type: 'string' }, created: { type: 'boolean' } },
380
+ required: ['path', 'created'],
381
+ },
382
+ },
383
+ {
384
+ name: 'move',
385
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
386
+ description: 'Move or rename a file/directory from "source" to "destination". Returns { source, destination }.',
387
+ inputSchema: {
388
+ type: 'object',
389
+ properties: {
390
+ source: { type: 'string', description: 'Existing path to move.' },
391
+ destination: { type: 'string', description: 'Target path.' },
392
+ },
393
+ required: ['source', 'destination'],
394
+ },
395
+ outputSchema: {
396
+ type: 'object',
397
+ properties: { source: { type: 'string' }, destination: { type: 'string' } },
398
+ required: ['source', 'destination'],
399
+ },
400
+ },
401
+ {
402
+ name: 'delete',
403
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
404
+ description: 'Delete a file or directory. Pass "recursive": true to remove a non-empty directory. Returns { path, deleted }.',
405
+ inputSchema: {
406
+ type: 'object',
407
+ properties: {
408
+ path: pathProp,
409
+ recursive: { type: 'boolean', description: 'Remove directories and their contents recursively.' },
410
+ },
411
+ required: ['path'],
412
+ },
413
+ outputSchema: {
414
+ type: 'object',
415
+ properties: { path: { type: 'string' }, deleted: { type: 'boolean' } },
416
+ required: ['path', 'deleted'],
417
+ },
418
+ },
419
+ {
420
+ name: 'get_allowed_directories',
421
+ annotations: READ_ONLY_ANNOTATIONS,
422
+ description: 'Returns the list of directories that this filesystem MCP server is ' +
423
+ 'currently allowed to access. The list is the merged result of ' +
424
+ 'server-level roots (configured via FLUJO MCP settings), MCP-node-level ' +
425
+ 'roots (set in the FlowBuilder), and the FLUJO_FS_ROOTS environment ' +
426
+ 'variable ceiling. When no roots are configured at all, returns the FLUJO ' +
427
+ 'data directory as the default (so the file browser is usable out of the box).',
428
+ inputSchema: {
429
+ type: 'object',
430
+ properties: {},
431
+ required: [],
432
+ },
433
+ outputSchema: {
434
+ type: 'object',
435
+ properties: {
436
+ directories: {
437
+ type: 'array',
438
+ items: { type: 'string' },
439
+ description: 'Absolute paths of all allowed directories.',
440
+ },
441
+ },
442
+ required: ['directories'],
443
+ },
444
+ },
445
+ ];
446
+ }
447
+ function splitLines(content) {
448
+ // Keep it simple + cross-platform: normalize CRLF/CR to LF for counting.
449
+ return content.replace(/\r\n?/g, '\n').split('\n');
450
+ }
451
+ /** Detect the dominant line ending of an existing file so writes stay consistent. */
452
+ function detectEol(content) {
453
+ return content.includes('\r\n') ? '\r\n' : '\n';
454
+ }
455
+ /** UTF-8 byte-order-mark character (U+FEFF) as it decodes into a JS string. */
456
+ const UTF8_BOM = '';
457
+ /**
458
+ * #254 (TOCTOU guard): SHA-256 (hex) of the RAW string as read from disk
459
+ * (before any CRLF/BOM normalization). `read_file` returns this as `contentHash`;
460
+ * the mutating tools accept it back as an optional `expectedHash` and refuse to
461
+ * write when the freshly-read file no longer hashes to it — catching the case
462
+ * where the file changed on disk during the (arbitrarily long) approval window.
463
+ */
464
+ function contentHash(content) {
465
+ return createHash('sha256').update(content, 'utf8').digest('hex');
466
+ }
467
+ /** Split a leading UTF-8 BOM off `content` so it can be re-attached after edit. */
468
+ function splitBom(content) {
469
+ return content.startsWith(UTF8_BOM)
470
+ ? { bom: UTF8_BOM, body: content.slice(UTF8_BOM.length) }
471
+ : { bom: '', body: content };
472
+ }
473
+ /** Standard actionable message when the on-disk file changed after approval. */
474
+ const STALE_CONTENT_MESSAGE = 'File changed after permission approval. Read it again before editing. No changes written.';
475
+ /** Heuristic binary sniff: a NUL byte in the first chunk means "not text". */
476
+ function looksBinary(buf) {
477
+ const n = Math.min(buf.length, 8000);
478
+ for (let i = 0; i < n; i++)
479
+ if (buf[i] === 0)
480
+ return true;
481
+ return false;
482
+ }
483
+ /**
484
+ * Stream a file line-by-line and collect the lines that contain `needle`
485
+ * (case-insensitive substring). Streaming means a match can be found without
486
+ * buffering the whole file. Stops early once `max` matches are collected.
487
+ */
488
+ async function grepFileLines(filePath, needle, max) {
489
+ const lower = needle.toLowerCase();
490
+ const matches = [];
491
+ let truncated = false;
492
+ const stream = createReadStream(filePath, { encoding: 'utf8' });
493
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
494
+ let lineNo = 0;
495
+ try {
496
+ for await (const line of rl) {
497
+ lineNo++;
498
+ if (line.toLowerCase().includes(lower)) {
499
+ matches.push({ line: lineNo, text: line.slice(0, 400) });
500
+ if (matches.length >= max) {
501
+ truncated = true;
502
+ break;
503
+ }
504
+ }
505
+ }
506
+ }
507
+ finally {
508
+ rl.close();
509
+ stream.destroy();
510
+ }
511
+ return { matches, truncated };
512
+ }
513
+ async function readSingleFileTool(args, roots) {
514
+ const filePath = await resolvePath(args.path, roots);
515
+ const hasRange = typeof args.from === 'number' || typeof args.to === 'number';
516
+ const pattern = typeof args.pattern === 'string' ? args.pattern.trim() : '';
517
+ // #287: guard whole-file reads of large files. Stat first so we never buffer a
518
+ // huge file just to reject it. An explicit line range or a `pattern` opts out.
519
+ let stat;
520
+ try {
521
+ stat = await fs.stat(filePath);
522
+ }
523
+ catch {
524
+ // fall through: fs.readFile below surfaces the real ENOENT/EACCES error.
525
+ }
526
+ if (stat && !stat.isFile()) {
527
+ throw new Error('Expected a regular file to read.');
528
+ }
529
+ const size = Number(stat?.size ?? 0);
530
+ if (size > LARGE_FILE_BYTES && !hasRange && !pattern) {
531
+ return errorResult(`File is large (${size} bytes > ${LARGE_FILE_BYTES} threshold). Reading it whole would flood the context. ` +
532
+ `Provide a "pattern" to grep for (matching lines + context are returned, then read targeted ranges with "from"/"to"), ` +
533
+ `or pass pattern "*" to read the whole file anyway.`);
534
+ }
535
+ // #287: pattern grep path (only when no explicit range was given). `*` is the
536
+ // escape hatch that means "read the whole file regardless".
537
+ if (pattern && pattern !== '*' && !hasRange) {
538
+ const buf = await fs.readFile(filePath);
539
+ if (looksBinary(buf)) {
540
+ return errorResult(`File appears to be binary; cannot grep for a pattern: ${filePath}`);
541
+ }
542
+ const content = buf.toString('utf8');
543
+ const lines = splitLines(content);
544
+ const totalLines = lines.length;
545
+ const lower = pattern.toLowerCase();
546
+ const matches = [];
547
+ for (let i = 0; i < lines.length; i++) {
548
+ if (lines[i].toLowerCase().includes(lower)) {
549
+ matches.push({ line: i + 1, text: lines[i].slice(0, 400) });
550
+ if (matches.length >= MAX_READ_PATTERN_MATCHES)
551
+ break;
552
+ }
553
+ }
554
+ const truncated = matches.length >= MAX_READ_PATTERN_MATCHES;
555
+ // Build a readable excerpt: matching lines with a small context window,
556
+ // line-numbered, with `…` separators between non-adjacent regions.
557
+ const show = new Set();
558
+ for (const m of matches) {
559
+ for (let l = m.line - READ_PATTERN_CONTEXT; l <= m.line + READ_PATTERN_CONTEXT; l++) {
560
+ if (l >= 1 && l <= totalLines)
561
+ show.add(l);
562
+ }
563
+ }
564
+ const ordered = [...show].sort((a, b) => a - b);
565
+ const parts = [];
566
+ let prev = 0;
567
+ for (const l of ordered) {
568
+ if (prev && l > prev + 1)
569
+ parts.push('…');
570
+ parts.push(`${l}: ${lines[l - 1]}`);
571
+ prev = l;
572
+ }
573
+ let out = parts.join('\n');
574
+ if (out.length > MAX_READ_CHARS)
575
+ out = out.slice(0, MAX_READ_CHARS) + '\n…[truncated]';
576
+ recordTouchedFile(filePath, 'read', size);
577
+ return dualResult({
578
+ path: filePath,
579
+ from: 1,
580
+ to: totalLines,
581
+ totalLines,
582
+ truncated,
583
+ content: matches.length ? out : `(no lines matched pattern ${JSON.stringify(pattern)})`,
584
+ matches,
585
+ contentHash: contentHash(content),
586
+ });
587
+ }
588
+ // Whole-file / explicit-range read (pattern '*' lands here too).
589
+ const content = await fs.readFile(filePath, 'utf8');
590
+ const lines = splitLines(content);
591
+ const totalLines = lines.length;
592
+ let from = typeof args.from === 'number' ? Math.max(1, Math.floor(args.from)) : 1;
593
+ let to = typeof args.to === 'number' ? Math.floor(args.to) : totalLines;
594
+ if (to < from)
595
+ [from, to] = [to, from];
596
+ to = Math.min(to, totalLines);
597
+ let out = hasRange ? lines.slice(from - 1, to).join('\n') : content;
598
+ let truncated = false;
599
+ if (out.length > MAX_READ_CHARS) {
600
+ out = out.slice(0, MAX_READ_CHARS) + '\n…[truncated]';
601
+ truncated = true;
602
+ }
603
+ recordTouchedFile(filePath, 'read', size);
604
+ return dualResult({ path: filePath, from: hasRange ? from : 1, to: hasRange ? to : totalLines, totalLines, truncated, content: out, contentHash: contentHash(content) });
605
+ }
606
+ function resultPayload(result) {
607
+ const structured = result.structuredContent;
608
+ if (structured)
609
+ return structured;
610
+ const text = result.content.find((item) => item.type === 'text');
611
+ if (!text || text.type !== 'text')
612
+ return { error: 'File read failed.' };
613
+ try {
614
+ const parsed = JSON.parse(text.text);
615
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
616
+ ? parsed
617
+ : { error: text.text };
618
+ }
619
+ catch {
620
+ return { error: text.text };
621
+ }
622
+ }
623
+ async function readFileTool(args, roots) {
624
+ const hasPath = Object.prototype.hasOwnProperty.call(args, 'path');
625
+ const hasPaths = Object.prototype.hasOwnProperty.call(args, 'paths');
626
+ if (hasPath && hasPaths)
627
+ throw new Error('Provide either "path" or "paths", not both.');
628
+ if (!hasPaths)
629
+ return await readSingleFileTool(args, roots);
630
+ if (!Array.isArray(args.paths) || args.paths.length === 0) {
631
+ throw new Error('Provide a non-empty "paths" array.');
632
+ }
633
+ if (args.paths.length > MAX_BATCH_READ_FILES) {
634
+ throw new Error(`Provide at most ${MAX_BATCH_READ_FILES} paths.`);
635
+ }
636
+ if (args.paths.some((item) => typeof item !== 'string' || !item.trim())) {
637
+ throw new Error('Every entry in "paths" must be a non-empty string.');
638
+ }
639
+ const files = [];
640
+ for (const requestedPath of args.paths) {
641
+ let resolvedPath;
642
+ let record;
643
+ try {
644
+ resolvedPath = await resolvePath(requestedPath, roots);
645
+ const result = await readSingleFileTool({ ...args, path: requestedPath, paths: undefined }, roots);
646
+ const payload = resultPayload(result);
647
+ record = result.isError
648
+ ? { requestedPath, path: resolvedPath, error: String(payload.error ?? 'File read failed.') }
649
+ : payload;
650
+ }
651
+ catch (err) {
652
+ record = {
653
+ requestedPath,
654
+ ...(resolvedPath ? { path: resolvedPath } : {}),
655
+ error: err instanceof Error ? err.message : String(err),
656
+ };
657
+ }
658
+ const candidate = { files: [...files, record] };
659
+ if (JSON.stringify(candidate).length > MAX_BATCH_READ_CHARS) {
660
+ record = {
661
+ requestedPath,
662
+ ...(resolvedPath ? { path: resolvedPath } : {}),
663
+ error: `Batch response limit of ${MAX_BATCH_READ_CHARS} serialized characters exceeded.`,
664
+ };
665
+ }
666
+ files.push(record);
667
+ }
668
+ return dualResult({ files });
669
+ }
670
+ async function writeFileTool(args, roots) {
671
+ const filePath = await resolvePath(args.path, roots);
672
+ const content = typeof args.content === 'string' ? args.content : '';
673
+ const mode = args.mode === 'append' || args.mode === 'insert' ? args.mode : 'overwrite';
674
+ const hasRange = typeof args.startLine === 'number' || typeof args.endLine === 'number';
675
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
676
+ recordTouchedFile(filePath, 'write');
677
+ // Whole-file overwrite (default, backward-compatible).
678
+ if (mode === 'overwrite' && !hasRange) {
679
+ await fs.writeFile(filePath, content, 'utf8');
680
+ return dualResult({ path: filePath, bytesWritten: Buffer.byteLength(content, 'utf8'), mode: 'overwrite' });
681
+ }
682
+ // The remaining modes operate relative to the existing file (empty if absent).
683
+ let existing = '';
684
+ try {
685
+ existing = await fs.readFile(filePath, 'utf8');
686
+ }
687
+ catch {
688
+ existing = '';
689
+ }
690
+ // #254 TOCTOU guard: for the read-modify-write modes (append/insert/range-
691
+ // overwrite) honor an optional expectedHash (the contentHash from read_file)
692
+ // and refuse to write when the file changed on disk since it was read.
693
+ const expectedHash = typeof args.expectedHash === 'string' ? args.expectedHash : undefined;
694
+ if (expectedHash && contentHash(existing) !== expectedHash) {
695
+ return errorResult(STALE_CONTENT_MESSAGE);
696
+ }
697
+ // #254 BOM preservation: operate on the body and re-attach a leading UTF-8 BOM
698
+ // (if the file had one) on write so the read-modify-write modes never drop it.
699
+ const { bom, body: existingBody } = splitBom(existing);
700
+ if (mode === 'append') {
701
+ const sep = existingBody.length && !existingBody.endsWith('\n') && !existingBody.endsWith('\r\n') ? detectEol(existingBody) : '';
702
+ const next = bom + existingBody + sep + content;
703
+ await fs.writeFile(filePath, next, 'utf8');
704
+ return dualResult({ path: filePath, bytesWritten: Buffer.byteLength(next, 'utf8'), mode: 'append' });
705
+ }
706
+ const eol = detectEol(existingBody);
707
+ const lines = existingBody.length ? existingBody.split(/\r?\n/) : [];
708
+ const insertLines = content.split(/\r?\n/);
709
+ const total = lines.length;
710
+ if (mode === 'insert') {
711
+ const at = typeof args.startLine === 'number' ? Math.max(1, Math.floor(args.startLine)) : total + 1;
712
+ const idx = Math.min(at - 1, total);
713
+ lines.splice(idx, 0, ...insertLines);
714
+ const next = bom + lines.join(eol);
715
+ await fs.writeFile(filePath, next, 'utf8');
716
+ return dualResult({ path: filePath, bytesWritten: Buffer.byteLength(next, 'utf8'), mode: 'insert', startLine: at, linesInserted: insertLines.length });
717
+ }
718
+ // overwrite a specific line range
719
+ let start = typeof args.startLine === 'number' ? Math.max(1, Math.floor(args.startLine)) : 1;
720
+ let end = typeof args.endLine === 'number' ? Math.floor(args.endLine) : start;
721
+ if (end < start)
722
+ [start, end] = [end, start];
723
+ end = Math.min(end, total);
724
+ const linesReplaced = Math.max(0, end - start + 1);
725
+ lines.splice(start - 1, linesReplaced, ...insertLines);
726
+ const next = bom + lines.join(eol);
727
+ await fs.writeFile(filePath, next, 'utf8');
728
+ return dualResult({ path: filePath, bytesWritten: Buffer.byteLength(next, 'utf8'), mode: 'overwrite', startLine: start, endLine: end, linesReplaced });
729
+ }
730
+ /**
731
+ * Char offsets [lo, hi) of the 1-based inclusive line range [start, end] within
732
+ * `text`. When both bounds are absent the whole string is returned. Offsets are
733
+ * computed by counting `\n`. PRECONDITION: `text` must be LF-normalized (the
734
+ * caller in `editFileTool` normalizes CR/CRLF to LF before matching), so every
735
+ * newline is exactly one character and the `+ 1` advance stays byte-exact.
736
+ */
737
+ function regionOffsets(text, start, end) {
738
+ if (start === undefined && end === undefined)
739
+ return { lo: 0, hi: text.length };
740
+ const lines = text.split('\n');
741
+ const s = Math.max(1, Math.floor(start ?? 1));
742
+ const e = Math.min(lines.length, Math.floor(end ?? lines.length));
743
+ let lo = 0;
744
+ for (let k = 0; k < s - 1 && k < lines.length; k++)
745
+ lo += lines[k].length + 1;
746
+ let hi = lo;
747
+ for (let k = s - 1; k < e && k < lines.length; k++)
748
+ hi += lines[k].length + (k < lines.length - 1 ? 1 : 0);
749
+ return { lo, hi: Math.max(lo, hi) };
750
+ }
751
+ async function editFileTool(args, roots) {
752
+ const filePath = await resolvePath(args.path, roots);
753
+ const hasDiff = typeof args.diff === 'string' && args.diff.trim().length > 0;
754
+ const hasEdits = Array.isArray(args.edits) && args.edits.length > 0;
755
+ if (hasDiff && hasEdits) {
756
+ return errorResult('Provide either "diff" or "edits", not both.');
757
+ }
758
+ const original = await fs.readFile(filePath, 'utf8');
759
+ // #254 TOCTOU guard: if the caller passed the contentHash it saw at read time
760
+ // and the file has since changed on disk (e.g. during the approval window),
761
+ // refuse to write against the new content rather than silently clobber it.
762
+ const expectedHash = typeof args.expectedHash === 'string' ? args.expectedHash : undefined;
763
+ if (expectedHash && contentHash(original) !== expectedHash) {
764
+ return errorResult(STALE_CONTENT_MESSAGE);
765
+ }
766
+ // #254 BOM preservation: strip a leading UTF-8 BOM before matching/normalizing
767
+ // (so oldText/diff context matches the real body) and re-attach it on write.
768
+ const { bom, body } = splitBom(original);
769
+ // (2) Unified-diff apply — atomic.
770
+ if (hasDiff) {
771
+ // Match in LF space so a CRLF file's trailing \r doesn't make every context
772
+ // line mismatch the (\r-stripped) diff body; restore the original EOL on
773
+ // write. The literal-edits path already did this (#187); the diff path did
774
+ // not, which meant diff apply could never succeed on a CRLF file.
775
+ const diffEol = detectEol(body);
776
+ const normalized = body.replace(/\r\n?/g, '\n');
777
+ let out;
778
+ try {
779
+ out = applyUnifiedDiff(normalized, args.diff);
780
+ }
781
+ catch (err) {
782
+ return errorResult(`Diff apply failed: ${err instanceof Error ? err.message : String(err)}. No changes written.`);
783
+ }
784
+ const finalContent = bom + (diffEol === '\r\n' ? out.result.replace(/\n/g, '\r\n') : out.result);
785
+ await fs.writeFile(filePath, finalContent, 'utf8');
786
+ recordTouchedFile(filePath, 'write');
787
+ return dualResult({ path: filePath, applied: true, mode: 'diff', diff: { added: out.added, removed: out.removed } });
788
+ }
789
+ // (1) Literal find/replace edits.
790
+ const edits = Array.isArray(args.edits) ? args.edits : [];
791
+ if (edits.length === 0) {
792
+ return errorResult('Provide a non-empty "edits" array of { oldText, newText } or a "diff" string.');
793
+ }
794
+ const gStart = typeof args.startLine === 'number' ? args.startLine : undefined;
795
+ const gEnd = typeof args.endLine === 'number' ? args.endLine : undefined;
796
+ // Match/apply in LF space so CR/CRLF differences between the file on disk and
797
+ // the model-supplied oldText don't produce spurious "not found" errors (#187).
798
+ // The file's original EOL is detected here and restored on write below.
799
+ const eol = detectEol(body);
800
+ let working = body.replace(/\r\n?/g, '\n');
801
+ let applied = 0;
802
+ for (const raw of edits) {
803
+ const edit = raw;
804
+ const oldText = typeof edit.oldText === 'string' ? edit.oldText.replace(/\r\n?/g, '\n') : '';
805
+ const newText = typeof edit.newText === 'string' ? edit.newText.replace(/\r\n?/g, '\n') : '';
806
+ if (!oldText)
807
+ return errorResult(`Edit #${applied + 1} has an empty "oldText".`);
808
+ const start = typeof edit.startLine === 'number' ? edit.startLine : gStart;
809
+ const end = typeof edit.endLine === 'number' ? edit.endLine : gEnd;
810
+ const scoped = start !== undefined || end !== undefined;
811
+ // Collect every occurrence in LF space, then resolve which one to edit.
812
+ const occurrences = [];
813
+ for (let p = working.indexOf(oldText); p !== -1; p = working.indexOf(oldText, p + 1)) {
814
+ occurrences.push(p);
815
+ }
816
+ if (occurrences.length === 0) {
817
+ return errorResult(`Edit #${applied + 1}: "oldText" not found in ${filePath}. No changes written.`);
818
+ }
819
+ // startLine/endLine are a disambiguation HINT, not a hard gate: if exactly
820
+ // one occurrence STARTS within the hinted range, use it (this also tolerates
821
+ // a multi-line match that extends past endLine). Otherwise fall back to a
822
+ // whole-file unambiguous match so a slightly-off line estimate never blocks
823
+ // an edit that is otherwise unique (#170 follow-up).
824
+ let idx = -1;
825
+ if (scoped) {
826
+ const { lo, hi } = regionOffsets(working, start, end);
827
+ const inRange = occurrences.filter((p) => p >= lo && p < hi);
828
+ if (inRange.length === 1)
829
+ idx = inRange[0];
830
+ }
831
+ if (idx === -1) {
832
+ if (occurrences.length === 1) {
833
+ idx = occurrences[0];
834
+ }
835
+ else {
836
+ return errorResult(`Edit #${applied + 1}: "oldText" is ambiguous (appears ${occurrences.length} times); add more surrounding context or a tighter startLine/endLine so exactly one occurrence is in range. No changes written.`);
837
+ }
838
+ }
839
+ working = working.slice(0, idx) + newText + working.slice(idx + oldText.length);
840
+ applied += 1;
841
+ }
842
+ const diff = buildLineDiff(body, working);
843
+ // Restore the file's original line-ending style so unchanged lines keep their
844
+ // bytes and we don't rewrite the whole file just because EOLs differ (#187).
845
+ // Re-attach any leading UTF-8 BOM that was stripped before matching (#254).
846
+ const finalContent = bom + (eol === '\r\n' ? working.replace(/\n/g, '\r\n') : working);
847
+ await fs.writeFile(filePath, finalContent, 'utf8');
848
+ recordTouchedFile(filePath, 'write');
849
+ return dualResult({ path: filePath, mode: 'edits', editsApplied: applied, diff });
850
+ }
851
+ /**
852
+ * Locate where a hunk's "old side" (its context + removed lines, in order)
853
+ * occurs in `origLines`. The unified-diff header's line number is treated as a
854
+ * HINT, not gospel: we prefer a match at the declared position but search
855
+ * outward from it so a stale/estimated line number still applies cleanly (the
856
+ * "fuzz" that GNU patch / `git apply` provide). `minPos` forbids matching before
857
+ * already-consumed lines. A pure-insertion hunk (empty old side) anchors at the
858
+ * hint. Throws when the context genuinely doesn't exist anywhere in the file.
859
+ */
860
+ function locateHunk(origLines, oldBlock, hint, minPos) {
861
+ if (oldBlock.length === 0) {
862
+ return Math.min(Math.max(hint, minPos), origLines.length);
863
+ }
864
+ const maxStart = origLines.length - oldBlock.length;
865
+ const matchesAt = (start) => {
866
+ if (start < minPos || start > maxStart)
867
+ return false;
868
+ for (let k = 0; k < oldBlock.length; k++) {
869
+ if (origLines[start + k] !== oldBlock[k])
870
+ return false;
871
+ }
872
+ return true;
873
+ };
874
+ const clampedHint = Math.max(minPos, hint);
875
+ if (matchesAt(clampedHint))
876
+ return clampedHint;
877
+ const radius = Math.max(clampedHint - minPos, maxStart - clampedHint);
878
+ for (let r = 1; r <= radius; r++) {
879
+ if (matchesAt(clampedHint - r))
880
+ return clampedHint - r;
881
+ if (matchesAt(clampedHint + r))
882
+ return clampedHint + r;
883
+ }
884
+ throw new Error(`could not locate hunk near line ${hint + 1}; its context/removed lines do not match the file. First expected line: ${JSON.stringify(oldBlock[0])}`);
885
+ }
886
+ /**
887
+ * Minimal, dependency-free unified-diff applier (issue #170 D1). Parses standard
888
+ * `@@ -oldStart,oldLen +newStart,newLen @@` hunks and applies them atomically:
889
+ * each hunk is RELOCATED to where its context (' ') + removed ('-') lines
890
+ * actually match the original (see locateHunk) rather than trusting the header's
891
+ * line number, so a slightly-off line number no longer hard-fails. Added ('+')
892
+ * lines are inserted; "" markers are ignored. Throws
893
+ * with no partial write when a hunk's context can't be found at all.
894
+ * PRECONDITION: `original` is LF-normalized by the caller.
895
+ */
896
+ function applyUnifiedDiff(original, diffText) {
897
+ const origLines = original.split('\n');
898
+ const diffLines = diffText.split(/\r?\n/);
899
+ const hunkHeader = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
900
+ // Parse the diff into hunks first so relocation can reason about a whole hunk.
901
+ const hunks = [];
902
+ let cur = null;
903
+ for (let i = 0; i < diffLines.length; i++) {
904
+ const line = diffLines[i];
905
+ if (line === undefined)
906
+ break;
907
+ const m = hunkHeader.exec(line);
908
+ if (m) {
909
+ cur = { oldStart: parseInt(m[1], 10), ops: [] };
910
+ hunks.push(cur);
911
+ continue;
912
+ }
913
+ if (line.startsWith('--- ') || line.startsWith('+++ ')) {
914
+ cur = null; // file header — leave the current hunk (if any)
915
+ continue;
916
+ }
917
+ if (!cur)
918
+ continue; // preamble before the first hunk
919
+ const tag = line.length ? line[0] : ' ';
920
+ const body = line.length ? line.slice(1) : '';
921
+ if (tag === ' ' || tag === '-' || tag === '+') {
922
+ cur.ops.push({ tag, body });
923
+ }
924
+ else if (tag === '\\') {
925
+ // "" — nothing to apply.
926
+ }
927
+ else {
928
+ throw new Error(`unexpected diff line: ${JSON.stringify(line)}`);
929
+ }
930
+ }
931
+ if (hunks.length === 0)
932
+ throw new Error('no unified-diff hunks (@@ ... @@) found');
933
+ const out = [];
934
+ let cursor = 0; // 0-based index into origLines already consumed/emitted
935
+ let added = 0;
936
+ let removed = 0;
937
+ for (const hunk of hunks) {
938
+ const oldBlock = hunk.ops.filter((o) => o.tag === ' ' || o.tag === '-').map((o) => o.body);
939
+ const pos = locateHunk(origLines, oldBlock, Math.max(0, hunk.oldStart - 1), cursor);
940
+ if (pos < cursor)
941
+ throw new Error('overlapping or out-of-order hunks');
942
+ while (cursor < pos) {
943
+ out.push(origLines[cursor]);
944
+ cursor++;
945
+ }
946
+ for (const op of hunk.ops) {
947
+ if (op.tag === ' ') {
948
+ out.push(origLines[cursor]);
949
+ cursor++;
950
+ }
951
+ else if (op.tag === '-') {
952
+ cursor++;
953
+ removed++;
954
+ }
955
+ else {
956
+ out.push(op.body);
957
+ added++;
958
+ }
959
+ }
960
+ }
961
+ while (cursor < origLines.length) {
962
+ out.push(origLines[cursor]);
963
+ cursor++;
964
+ }
965
+ return { result: out.join('\n'), added, removed };
966
+ }
967
+ /** Minimal line-level diff summary (added/removed counts). */
968
+ function buildLineDiff(before, after) {
969
+ const a = splitLines(before);
970
+ const b = splitLines(after);
971
+ const bSet = new Map();
972
+ for (const line of b)
973
+ bSet.set(line, (bSet.get(line) ?? 0) + 1);
974
+ const aSet = new Map();
975
+ for (const line of a)
976
+ aSet.set(line, (aSet.get(line) ?? 0) + 1);
977
+ let removed = 0;
978
+ for (const [line, count] of aSet)
979
+ removed += Math.max(0, count - (bSet.get(line) ?? 0));
980
+ let added = 0;
981
+ for (const [line, count] of bSet)
982
+ added += Math.max(0, count - (aSet.get(line) ?? 0));
983
+ return { added, removed };
984
+ }
985
+ async function entryType(full) {
986
+ try {
987
+ const st = await fs.lstat(full);
988
+ if (st.isFile())
989
+ return 'file';
990
+ if (st.isDirectory())
991
+ return 'directory';
992
+ return 'other';
993
+ }
994
+ catch {
995
+ return 'other';
996
+ }
997
+ }
998
+ async function listDirTool(args, roots) {
999
+ const dirPath = await resolvePath(args.path, roots);
1000
+ const names = await fs.readdir(dirPath);
1001
+ const entries = await Promise.all(names.map(async (name) => {
1002
+ const full = path.join(dirPath, name);
1003
+ const type = await entryType(full);
1004
+ let size = 0;
1005
+ try {
1006
+ size = (await fs.stat(full)).size;
1007
+ }
1008
+ catch {
1009
+ /* ignore */
1010
+ }
1011
+ return { name, type, size };
1012
+ }));
1013
+ entries.sort((x, y) => (x.type === y.type ? x.name.localeCompare(y.name) : x.type === 'directory' ? -1 : 1));
1014
+ return dualResult({ path: dirPath, entries });
1015
+ }
1016
+ async function dirTreeTool(args, roots) {
1017
+ const rootPath = await resolvePath(args.path, roots);
1018
+ const depth = Math.min(typeof args.depth === 'number' ? Math.max(1, Math.floor(args.depth)) : DEFAULT_TREE_DEPTH, MAX_TREE_DEPTH);
1019
+ let count = 0;
1020
+ let truncated = false;
1021
+ async function walk(dir, level) {
1022
+ if (level > depth || truncated)
1023
+ return [];
1024
+ let names;
1025
+ try {
1026
+ names = await fs.readdir(dir);
1027
+ }
1028
+ catch {
1029
+ return [];
1030
+ }
1031
+ names.sort((a, b) => a.localeCompare(b));
1032
+ const nodes = [];
1033
+ for (const name of names) {
1034
+ if (++count > MAX_TREE_ENTRIES) {
1035
+ truncated = true;
1036
+ break;
1037
+ }
1038
+ const full = path.join(dir, name);
1039
+ const type = await entryType(full);
1040
+ const node = { name, type };
1041
+ if (type === 'directory' && level < depth) {
1042
+ node.children = await walk(full, level + 1);
1043
+ }
1044
+ nodes.push(node);
1045
+ }
1046
+ return nodes;
1047
+ }
1048
+ const tree = await walk(rootPath, 1);
1049
+ return dualResult({ path: rootPath, depth, truncated, tree });
1050
+ }
1051
+ /**
1052
+ * #287: content-scan one file for `needle`. Skips oversized and likely-binary
1053
+ * files up front (a huge perf/robustness win over reading every file whole),
1054
+ * then streams it line-by-line so a match is found without buffering the file.
1055
+ */
1056
+ async function scanFileContent(full, needle, remaining) {
1057
+ let st;
1058
+ try {
1059
+ st = await fs.stat(full);
1060
+ }
1061
+ catch {
1062
+ return [];
1063
+ }
1064
+ if (!st.isFile() || st.size > SEARCH_MAX_FILE_BYTES)
1065
+ return [];
1066
+ // Binary sniff on the first chunk without loading the whole file.
1067
+ try {
1068
+ const fh = await fs.open(full, 'r');
1069
+ try {
1070
+ const probe = Buffer.alloc(Math.min(8000, st.size));
1071
+ if (probe.length)
1072
+ await fh.read(probe, 0, probe.length, 0);
1073
+ if (looksBinary(probe))
1074
+ return [];
1075
+ }
1076
+ finally {
1077
+ await fh.close();
1078
+ }
1079
+ }
1080
+ catch {
1081
+ return [];
1082
+ }
1083
+ const { matches } = await grepFileLines(full, needle, Math.max(1, remaining));
1084
+ return matches.map((m) => ({ path: full, line: m.line, text: m.text }));
1085
+ }
1086
+ async function searchTool(args, roots) {
1087
+ const rootPath = await resolvePath(args.path, roots);
1088
+ const namePattern = typeof args.namePattern === 'string' ? args.namePattern.toLowerCase() : '';
1089
+ const contentPattern = typeof args.content === 'string' ? args.content : '';
1090
+ if (!namePattern && !contentPattern) {
1091
+ return errorResult('Provide "namePattern" and/or "content" to search for.');
1092
+ }
1093
+ const matches = [];
1094
+ let truncated = false;
1095
+ // First pass: walk the tree collecting NAME matches immediately and gathering
1096
+ // the list of candidate files for a (possibly parallel) content scan.
1097
+ const files = [];
1098
+ async function walk(dir) {
1099
+ if (truncated)
1100
+ return;
1101
+ let names;
1102
+ try {
1103
+ names = await fs.readdir(dir);
1104
+ }
1105
+ catch {
1106
+ return;
1107
+ }
1108
+ for (const name of names) {
1109
+ if (truncated)
1110
+ return;
1111
+ const full = path.join(dir, name);
1112
+ const type = await entryType(full);
1113
+ if (namePattern && name.toLowerCase().includes(namePattern)) {
1114
+ matches.push({ path: full });
1115
+ if (matches.length >= MAX_SEARCH_RESULTS) {
1116
+ truncated = true;
1117
+ return;
1118
+ }
1119
+ }
1120
+ if (type === 'directory') {
1121
+ await walk(full);
1122
+ }
1123
+ else if (type === 'file' && contentPattern) {
1124
+ files.push(full);
1125
+ }
1126
+ }
1127
+ }
1128
+ await walk(rootPath);
1129
+ // Second pass: scan file contents with bounded concurrency and early stop.
1130
+ if (contentPattern && !truncated) {
1131
+ for (let i = 0; i < files.length && !truncated; i += SEARCH_CONCURRENCY) {
1132
+ const batch = files.slice(i, i + SEARCH_CONCURRENCY);
1133
+ const results = await Promise.all(batch.map((full) => scanFileContent(full, contentPattern, MAX_SEARCH_RESULTS)));
1134
+ for (const fileMatches of results) {
1135
+ for (const m of fileMatches) {
1136
+ matches.push(m);
1137
+ if (matches.length >= MAX_SEARCH_RESULTS) {
1138
+ truncated = true;
1139
+ break;
1140
+ }
1141
+ }
1142
+ if (truncated)
1143
+ break;
1144
+ }
1145
+ }
1146
+ }
1147
+ return dualResult({ matches, truncated });
1148
+ }
1149
+ async function getFileInfoTool(args, roots) {
1150
+ const target = await resolvePath(args.path, roots);
1151
+ const st = await fs.stat(target);
1152
+ return dualResult({
1153
+ path: target,
1154
+ type: st.isFile() ? 'file' : st.isDirectory() ? 'directory' : 'other',
1155
+ size: st.size,
1156
+ isFile: st.isFile(),
1157
+ isDirectory: st.isDirectory(),
1158
+ createdAt: st.birthtime.toISOString(),
1159
+ modifiedAt: st.mtime.toISOString(),
1160
+ });
1161
+ }
1162
+ async function createDirectoryTool(args, roots) {
1163
+ const dirPath = await resolvePath(args.path, roots);
1164
+ await fs.mkdir(dirPath, { recursive: true });
1165
+ return dualResult({ path: dirPath, created: true });
1166
+ }
1167
+ async function moveTool(args, roots) {
1168
+ const source = await resolvePath(args.source, roots);
1169
+ const destination = await resolvePath(args.destination, roots);
1170
+ await fs.mkdir(path.dirname(destination), { recursive: true });
1171
+ await fs.rename(source, destination);
1172
+ return dualResult({ source, destination });
1173
+ }
1174
+ async function deleteTool(args, roots) {
1175
+ const target = await resolvePath(args.path, roots);
1176
+ const recursive = args.recursive === true;
1177
+ await fs.rm(target, { recursive, force: false });
1178
+ return dualResult({ path: target, deleted: true });
1179
+ }
1180
+ async function getAllowedDirectoriesTool(roots) {
1181
+ return dualResult({ directories: roots });
1182
+ }
1183
+ export async function filesystemCallTool(toolName, args, callerNodeId) {
1184
+ try {
1185
+ // MCP App launcher (#97): pure UI trigger — returns immediately without
1186
+ // touching the filesystem. The app renders in chat; the user's pick returns
1187
+ // as a follow-up message. Handled before roots are resolved (it needs none).
1188
+ if (toolName === 'file_browser_ui') {
1189
+ return textResult('File browser shown to the user. Waiting for them to select a file — their choice will arrive as a follow-up message. Do not proceed until then.');
1190
+ }
1191
+ const roots = await loadEffectiveRoots(FILESYSTEM_SERVER_NAME, 'FLUJO_FS_ROOTS', callerNodeId);
1192
+ switch (toolName) {
1193
+ case 'read_file':
1194
+ return await readFileTool(args, roots);
1195
+ case 'write_file':
1196
+ return await writeFileTool(args, roots);
1197
+ case 'edit_file':
1198
+ return await editFileTool(args, roots);
1199
+ case 'list_dir':
1200
+ return await listDirTool(args, roots);
1201
+ case 'dir_tree':
1202
+ return await dirTreeTool(args, roots);
1203
+ case 'search':
1204
+ return await searchTool(args, roots);
1205
+ case 'get_file_info':
1206
+ return await getFileInfoTool(args, roots);
1207
+ case 'create_directory':
1208
+ return await createDirectoryTool(args, roots);
1209
+ case 'move':
1210
+ return await moveTool(args, roots);
1211
+ case 'delete':
1212
+ return await deleteTool(args, roots);
1213
+ case 'get_allowed_directories':
1214
+ return await getAllowedDirectoriesTool(roots);
1215
+ default:
1216
+ return errorResult(`Unknown tool on the built-in filesystem server: ${toolName}`);
1217
+ }
1218
+ }
1219
+ catch (err) {
1220
+ const msg = err instanceof Error ? err.message : String(err);
1221
+ log.warn('filesystemCallTool failed', { toolName, err });
1222
+ // Do not attach structuredContent to an error. Every successful tool has
1223
+ // its own outputSchema, and `{ error }` does not conform to those schemas;
1224
+ // strict MCP clients validate it before the file-browser View can inspect
1225
+ // `isError`. The JSON text block remains machine-readable by payloadOf().
1226
+ return errorResult(msg);
1227
+ }
1228
+ }
1229
+ //# sourceMappingURL=tools.js.map