@mario.andreschak/mcp-filesystem 3.45.0 → 3.45.2

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 CHANGED
@@ -14,20 +14,18 @@
14
14
  * as a HARD CEILING an operator sets — no path may ever escape it, and
15
15
  * - user-configured roots persisted via the MCP manager UI (issue #170), which
16
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.
17
+ * When neither is set the server has full host access.
21
18
  *
22
19
  * Every tool returns a machine-readable JSON envelope both as MCP
23
20
  * `structuredContent` and as a single text content block (for backward-compat
24
21
  * clients); errors are returned as `isError: true` results rather than thrown.
25
22
  */
26
23
  import path from 'node:path';
27
- import { promises as fs, createReadStream } from 'node:fs';
24
+ import { promises as fs } from 'node:fs';
25
+ import { spawn } from 'node:child_process';
28
26
  import { createHash } from 'node:crypto';
29
27
  import readline from 'node:readline';
30
- import { ALLOW_PROTECTED_PATHS_ENV, createLogger, getDataDir, isInside, isProtected, isProtectedPathsEnabled, loadEffectiveRoots, } from './shared.js';
28
+ import { createLogger, getDataDir, isInside, loadEffectiveRoots, } from './shared.js';
31
29
  import { recordTouchedFile } from './resources.js';
32
30
  import { detectMediaFile, mimeTypeFromExtension, mediaTypeFromMime, looksBinaryHeuristic } from './media.js';
33
31
  const FILESYSTEM_SERVER_NAME = 'filesystem';
@@ -79,10 +77,90 @@ const MAX_MEDIA_BYTES = 32 * 1024 * 1024;
79
77
  const SEARCH_MAX_FILE_BYTES = 5_000_000;
80
78
  /** #287: how many files to scan concurrently during a content search. */
81
79
  const SEARCH_CONCURRENCY = 16;
80
+ /** Directory reads are independent I/O; a bounded breadth-first batch avoids
81
+ * serially walking every directory on large workspaces. */
82
+ const SEARCH_DIRECTORY_CONCURRENCY = 32;
83
+ /** Do not turn a fast filesystem walk into a notification flood. */
84
+ const SEARCH_PROGRESS_INTERVAL_MS = 500;
85
+ /** Silent searches still report liveness so the host can distinguish work from a hang. */
86
+ const SEARCH_PROGRESS_HEARTBEAT_MS = 5_000;
82
87
  /** #287: context lines shown around each `read_file` pattern match. */
83
88
  const READ_PATTERN_CONTEXT = 2;
84
89
  /** #287: cap on matches returned by a single `read_file` pattern grep. */
85
90
  const MAX_READ_PATTERN_MATCHES = 200;
91
+ /** #487: bound caller-controlled regular expressions before compiling them. */
92
+ const MAX_READ_PATTERN_LENGTH = 4_096;
93
+ /**
94
+ * Coalesced MCP progress for search. The numeric field is a notification
95
+ * sequence (MCP requires it to increase); useful counters live in the message.
96
+ */
97
+ function createSearchProgressReporter(context) {
98
+ const report = context?.onProgress;
99
+ if (!report)
100
+ return { update: () => undefined, stop: async () => undefined };
101
+ let snapshot = {
102
+ phase: 'starting',
103
+ directories: 0,
104
+ files: 0,
105
+ matches: 0,
106
+ };
107
+ let sequence = 0;
108
+ let lastDeliveredAt = 0;
109
+ let stopped = false;
110
+ let chain = Promise.resolve();
111
+ const deliver = () => {
112
+ if (stopped || context?.signal?.aborted)
113
+ return;
114
+ lastDeliveredAt = Date.now();
115
+ const current = ++sequence;
116
+ const message = `${snapshot.phase}: ${snapshot.directories} directories, ${snapshot.files} files, ${snapshot.matches} matches`;
117
+ chain = chain
118
+ .then(() => report({ progress: current, message }))
119
+ .catch((error) => log.debug('Could not deliver filesystem search progress', error));
120
+ };
121
+ const heartbeat = setInterval(deliver, SEARCH_PROGRESS_HEARTBEAT_MS);
122
+ heartbeat.unref?.();
123
+ return {
124
+ update: (patch, force = false) => {
125
+ snapshot = { ...snapshot, ...patch };
126
+ if (force || Date.now() - lastDeliveredAt >= SEARCH_PROGRESS_INTERVAL_MS)
127
+ deliver();
128
+ },
129
+ stop: async () => {
130
+ if (stopped)
131
+ return;
132
+ clearInterval(heartbeat);
133
+ if (!context?.signal?.aborted && Date.now() - lastDeliveredAt > 0)
134
+ deliver();
135
+ stopped = true;
136
+ await chain;
137
+ },
138
+ };
139
+ }
140
+ /**
141
+ * ripgrep is an installer-provided acceleration, not a runtime requirement.
142
+ * Cache by PATH value so a process whose environment is refreshed after an
143
+ * install can discover it without restarting. `undefined` means automatic,
144
+ * while `null` is a test-only forced fallback.
145
+ */
146
+ let ripgrepCache;
147
+ let ripgrepExecutableForTests;
148
+ const activeRipgrepChildren = new Set();
149
+ /** Test seam: force a ripgrep executable, force the Node fallback with null, or reset with undefined. */
150
+ export function _setRipgrepExecutableForTests(value) {
151
+ ripgrepExecutableForTests = value;
152
+ ripgrepCache = undefined;
153
+ }
154
+ /** Kill external search accelerators before the filesystem MCP process exits. */
155
+ export function shutdownFilesystemSearches() {
156
+ for (const child of activeRipgrepChildren) {
157
+ try {
158
+ child.kill();
159
+ }
160
+ catch { /* best-effort shutdown */ }
161
+ }
162
+ activeRipgrepChildren.clear();
163
+ }
86
164
  /** Return a structured payload as BOTH MCP structuredContent and a text fallback. */
87
165
  function dualResult(payload) {
88
166
  return {
@@ -104,6 +182,19 @@ function errorResult(message) {
104
182
  isError: true,
105
183
  };
106
184
  }
185
+ /**
186
+ * Translate the slash-drive form some models emit into a native Windows path.
187
+ * Keep the platform injectable so the narrow conversion can be verified on any
188
+ * host; all canonicalization and confinement still happen in resolvePath().
189
+ */
190
+ export function _normalizeSlashDrivePathForTests(raw, platform = process.platform) {
191
+ const match = /^\/([A-Za-z])(?:\/|$)/.exec(raw);
192
+ if (platform !== 'win32' || !match)
193
+ return raw;
194
+ const driveRoot = `${match[1].toUpperCase()}:\\`;
195
+ const remainder = raw.slice(match[0].length).split('/').join(path.win32.sep);
196
+ return remainder ? `${driveRoot}${remainder}` : driveRoot;
197
+ }
107
198
  /**
108
199
  * Resolve a user-supplied path against the data dir (for relative paths) and
109
200
  * enforce the effective confinement roots. An empty roots array blocks all access.
@@ -113,32 +204,23 @@ async function resolvePath(input, roots) {
113
204
  const raw = typeof input === 'string' ? input.trim() : '';
114
205
  if (!raw)
115
206
  throw new Error('Provide "path".');
207
+ const normalized = _normalizeSlashDrivePathForTests(raw);
116
208
  const dataDir = getDataDir();
117
- const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(dataDir, raw);
118
- // Optional defense-in-depth layer (issue #260). Configured roots win by
119
- // default; users can opt into this stricter policy under Experimental Features.
120
- if (await isProtectedPathsEnabled()) {
121
- const prot = isProtected(resolved);
122
- if (prot.denied) {
123
- throw new Error(`Path "${resolved}" is within a protected location (${prot.matchedRoot}) and is blocked by the FLUJO built-in server protected-path policy. ` +
124
- `Disable "Protect sensitive home-directory paths" in Experimental Features or set ${ALLOW_PROTECTED_PATHS_ENV}=1 to override.`);
125
- }
126
- }
209
+ const resolved = path.isAbsolute(normalized)
210
+ ? path.resolve(normalized)
211
+ : path.resolve(dataDir, normalized);
127
212
  if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
128
213
  throw new Error(`Path "${resolved}" is outside the configured filesystem roots.`);
129
214
  }
130
215
  return resolved;
131
216
  }
132
217
  export function filesystemToolDefinitions() {
133
- const pathProp = { type: 'string', description: 'File or directory path. Relative paths resolve against the FLUJO data directory; absolute paths are used as-is.' };
218
+ const pathProp = { type: 'string', description: 'File or directory path. Relative paths start at the FLUJO data directory.' };
134
219
  return [
135
220
  {
136
221
  name: 'read_file',
137
222
  annotations: READ_ONLY_ANNOTATIONS,
138
- 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. ' +
139
- `Media files (images, audio, video) are detected automatically and returned as MCP media content items; read them with "path" alone ("pattern"/"from"/"to" do not apply to binary media, and the large-file rule below is waived for them, up to ${MAX_MEDIA_BYTES / (1024 * 1024)} MB). FLUJO persists the bytes as a run resource and re-attaches them to the conversation as a real media input, so a model that accepts that modality perceives the file directly; a model that does not receives the run-resource URI and can still pass it to other tools. ` +
140
- `For large NON-MEDIA 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. ` +
141
- 'Single text reads return { path, from, to, totalLines, content, truncated, contentHash, matches? } unchanged; media reads return { path, mediaType, mimeType, size, encoding } alongside the media item. 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).',
223
+ description: `Read text or media files. Use "path" for one file or "paths" for up to 25; do not use both. For text, "from"/"to" select lines and "pattern" is a case-insensitive regular expression. Regex metacharacters have their usual meaning and must be escaped for literal matching; invalid expressions or expressions over ${MAX_READ_PATTERN_LENGTH} characters return an error. Files over ${LARGE_FILE_BYTES / 1000} KB need a line range or pattern; the exact pattern "*" is a reserved sentinel that reads the whole file. Media limit: ${MAX_MEDIA_BYTES / (1024 * 1024)} MB. Use the returned contentHash as expectedHash when editing.`,
142
224
  inputSchema: {
143
225
  type: 'object',
144
226
  properties: {
@@ -152,7 +234,7 @@ export function filesystemToolDefinitions() {
152
234
  },
153
235
  from: { type: 'number', description: 'Optional 1-based first line to return (inclusive).' },
154
236
  to: { type: 'number', description: 'Optional 1-based last line to return (inclusive).' },
155
- 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.' },
237
+ pattern: { type: 'string', description: `Case-insensitive regular expression, limited to ${MAX_READ_PATTERN_LENGTH} characters. Regex metacharacters have their usual meaning; escape them for a literal match. Invalid or over-limit expressions return a tool error. Required to read a large file whole without a "from"/"to" range; matching lines plus context are returned instead of the full body. The exact value "*" is reserved to read the whole file.` },
156
238
  },
157
239
  },
158
240
  outputSchema: {
@@ -184,7 +266,7 @@ export function filesystemToolDefinitions() {
184
266
  {
185
267
  name: 'write_file',
186
268
  annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
187
- 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, ... }.',
269
+ description: 'Write a text file and create missing parent directories. Modes: overwrite (default), append, or insert before startLine. For a range overwrite, give startLine and endLine. Use expectedHash from read_file to reject changes to a stale file; it is ignored for whole-file overwrite.',
188
270
  // #216: feed the docked diff canvas (ui://devcanvas/diff) so successive
189
271
  // writes update one persistent tab. See internal/filesystemResources.ts.
190
272
  _meta: { ui: { resourceUri: 'ui://devcanvas/diff' } },
@@ -217,7 +299,7 @@ export function filesystemToolDefinitions() {
217
299
  {
218
300
  name: 'edit_file',
219
301
  annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
220
- 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} }.',
302
+ description: 'Edit a text file with either "edits" or "diff"; do not use both. Each oldText must match once, so include enough context to make it unique. A unified diff is applied atomically. Use expectedHash from read_file to reject edits to a stale file.',
221
303
  // #216: also feed the docked diff canvas so edits show live in the canvas.
222
304
  _meta: { ui: { resourceUri: 'ui://devcanvas/diff' } },
223
305
  inputSchema: {
@@ -264,7 +346,7 @@ export function filesystemToolDefinitions() {
264
346
  {
265
347
  name: 'list_dir',
266
348
  annotations: READ_ONLY_ANNOTATIONS,
267
- description: 'List the entries of a directory. Returns { path, entries: [{ name, type, size }] } where type is "file" | "directory" | "other".',
349
+ description: 'List a directory. Each entry includes name, type, and size.',
268
350
  inputSchema: {
269
351
  type: 'object',
270
352
  properties: { path: pathProp },
@@ -289,7 +371,7 @@ export function filesystemToolDefinitions() {
289
371
  {
290
372
  name: 'file_browser_ui',
291
373
  annotations: READ_ONLY_ANNOTATIONS,
292
- 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.',
374
+ description: 'Ask the user to pick a file in an interactive browser. This returns before they choose. After calling it, STOP and wait for a follow-up user message containing the selected path.',
293
375
  // MCP Apps (#97): this tool exists solely to surface the file-browser app
294
376
  // (ui://filesystem/browser). See internal/filesystemResources.ts.
295
377
  _meta: { ui: { resourceUri: 'ui://filesystem/browser' } },
@@ -304,7 +386,7 @@ export function filesystemToolDefinitions() {
304
386
  {
305
387
  name: 'dir_tree',
306
388
  annotations: READ_ONLY_ANNOTATIONS,
307
- description: 'Return a recursive, depth-limited directory tree as nested JSON. Returns { path, depth, tree }.',
389
+ description: 'List a directory tree recursively, limited by depth.',
308
390
  inputSchema: {
309
391
  type: 'object',
310
392
  properties: {
@@ -327,7 +409,7 @@ export function filesystemToolDefinitions() {
327
409
  {
328
410
  name: 'search',
329
411
  annotations: READ_ONLY_ANNOTATIONS,
330
- 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 }.',
412
+ description: 'Search a directory tree by namePattern and/or text content. Matching is case-insensitive. Content matches include line numbers. Binary and very large files are skipped.',
331
413
  inputSchema: {
332
414
  type: 'object',
333
415
  properties: {
@@ -356,7 +438,7 @@ export function filesystemToolDefinitions() {
356
438
  {
357
439
  name: 'get_file_info',
358
440
  annotations: READ_ONLY_ANNOTATIONS,
359
- description: 'Stat a path. Returns { path, type, size, isFile, isDirectory, createdAt, modifiedAt }.',
441
+ description: 'Get a path\'s type, size, and creation and modification times.',
360
442
  inputSchema: {
361
443
  type: 'object',
362
444
  properties: { path: pathProp },
@@ -379,7 +461,7 @@ export function filesystemToolDefinitions() {
379
461
  {
380
462
  name: 'create_directory',
381
463
  annotations: WRITE_ANNOTATIONS,
382
- description: 'Create a directory (recursively). Succeeds if it already exists. Returns { path, created }.',
464
+ description: 'Create a directory and any missing parents. Succeeds if it exists.',
383
465
  inputSchema: {
384
466
  type: 'object',
385
467
  properties: { path: pathProp },
@@ -394,7 +476,7 @@ export function filesystemToolDefinitions() {
394
476
  {
395
477
  name: 'move',
396
478
  annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
397
- description: 'Move or rename a file/directory from "source" to "destination". Returns { source, destination }.',
479
+ description: 'Move or rename a file or directory.',
398
480
  inputSchema: {
399
481
  type: 'object',
400
482
  properties: {
@@ -412,7 +494,7 @@ export function filesystemToolDefinitions() {
412
494
  {
413
495
  name: 'delete',
414
496
  annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
415
- description: 'Delete a file or directory. Pass "recursive": true to remove a non-empty directory. Returns { path, deleted }.',
497
+ description: 'Delete a file or directory. Set recursive to true for a non-empty directory.',
416
498
  inputSchema: {
417
499
  type: 'object',
418
500
  properties: {
@@ -430,12 +512,7 @@ export function filesystemToolDefinitions() {
430
512
  {
431
513
  name: 'get_allowed_directories',
432
514
  annotations: READ_ONLY_ANNOTATIONS,
433
- description: 'Returns the list of directories that this filesystem MCP server is ' +
434
- 'currently allowed to access. The list is the merged result of ' +
435
- 'server-level roots (configured via FLUJO MCP settings), MCP-node-level ' +
436
- 'roots (set in the FlowBuilder), and the FLUJO_FS_ROOTS environment ' +
437
- 'variable ceiling. When no roots are configured at all, returns the FLUJO ' +
438
- 'data directory as the default (so the file browser is usable out of the box).',
515
+ description: 'List the directories this server is allowed to access.',
439
516
  inputSchema: {
440
517
  type: 'object',
441
518
  properties: {},
@@ -526,40 +603,28 @@ function mediaResult(filePath, buf, mediaType, mimeType) {
526
603
  };
527
604
  return result;
528
605
  }
529
- /**
530
- * Stream a file line-by-line and collect the lines that contain `needle`
531
- * (case-insensitive substring). Streaming means a match can be found without
532
- * buffering the whole file. Stops early once `max` matches are collected.
533
- */
534
- async function grepFileLines(filePath, needle, max) {
535
- const lower = needle.toLowerCase();
536
- const matches = [];
537
- let truncated = false;
538
- const stream = createReadStream(filePath, { encoding: 'utf8' });
539
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
540
- let lineNo = 0;
606
+ function compileReadPattern(pattern) {
607
+ if (!pattern || pattern === '*')
608
+ return undefined;
609
+ if (pattern.length > MAX_READ_PATTERN_LENGTH) {
610
+ throw new Error(`Invalid regular expression: pattern exceeds the ${MAX_READ_PATTERN_LENGTH}-character limit.`);
611
+ }
541
612
  try {
542
- for await (const line of rl) {
543
- lineNo++;
544
- if (line.toLowerCase().includes(lower)) {
545
- matches.push({ line: lineNo, text: line.slice(0, 400) });
546
- if (matches.length >= max) {
547
- truncated = true;
548
- break;
549
- }
550
- }
551
- }
613
+ return new RegExp(pattern, 'i');
552
614
  }
553
- finally {
554
- rl.close();
555
- stream.destroy();
615
+ catch (err) {
616
+ const detail = err instanceof Error
617
+ ? err.message.replace(/^Invalid regular expression:\s*/i, '')
618
+ : 'the pattern could not be compiled.';
619
+ throw new Error(`Invalid regular expression: ${detail}`);
556
620
  }
557
- return { matches, truncated };
558
621
  }
559
622
  async function readSingleFileTool(args, roots) {
560
623
  const filePath = await resolvePath(args.path, roots);
561
624
  const hasRange = typeof args.from === 'number' || typeof args.to === 'number';
562
- const pattern = typeof args.pattern === 'string' ? args.pattern.trim() : '';
625
+ const suppliedPattern = typeof args.pattern === 'string' ? args.pattern : '';
626
+ const pattern = suppliedPattern.trim() ? suppliedPattern : '';
627
+ const patternRegex = compileReadPattern(pattern);
563
628
  // #287: guard whole-file reads of large files. Stat first so we never buffer a
564
629
  // huge file just to reject it. An explicit line range or a `pattern` opts out.
565
630
  let stat;
@@ -629,10 +694,12 @@ async function readSingleFileTool(args, roots) {
629
694
  const content = buf.toString('utf8');
630
695
  const lines = splitLines(content);
631
696
  const totalLines = lines.length;
632
- const lower = pattern.toLowerCase();
633
697
  const matches = [];
634
698
  for (let i = 0; i < lines.length; i++) {
635
- if (lines[i].toLowerCase().includes(lower)) {
699
+ // The current expression has no stateful flags, but resetting lastIndex
700
+ // keeps this safe if flags change later.
701
+ patternRegex.lastIndex = 0;
702
+ if (patternRegex.test(lines[i])) {
636
703
  matches.push({ line: i + 1, text: lines[i].slice(0, 400) });
637
704
  if (matches.length >= MAX_READ_PATTERN_MATCHES)
638
705
  break;
@@ -1157,103 +1224,368 @@ async function dirTreeTool(args, roots) {
1157
1224
  const tree = await walk(rootPath, 1);
1158
1225
  return dualResult({ path: rootPath, depth, truncated, tree });
1159
1226
  }
1227
+ function pathEnvironmentValue() {
1228
+ const entry = Object.entries(process.env).find(([name]) => name.toLowerCase() === 'path');
1229
+ return entry?.[1] ?? '';
1230
+ }
1231
+ /** Locate the installer-provided ripgrep without invoking a shell. */
1232
+ async function resolveRipgrepExecutable() {
1233
+ if (ripgrepExecutableForTests !== undefined)
1234
+ return ripgrepExecutableForTests;
1235
+ const pathValue = pathEnvironmentValue();
1236
+ if (ripgrepCache?.pathValue === pathValue)
1237
+ return ripgrepCache.executable;
1238
+ const names = process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg'];
1239
+ let executable = null;
1240
+ for (const rawDir of pathValue.split(path.delimiter)) {
1241
+ const dir = rawDir.trim().replace(/^"(.*)"$/, '$1');
1242
+ if (!dir)
1243
+ continue;
1244
+ for (const name of names) {
1245
+ const candidate = path.join(dir, name);
1246
+ try {
1247
+ if ((await fs.stat(candidate)).isFile()) {
1248
+ executable = candidate;
1249
+ break;
1250
+ }
1251
+ }
1252
+ catch {
1253
+ /* not in this PATH directory */
1254
+ }
1255
+ }
1256
+ if (executable)
1257
+ break;
1258
+ }
1259
+ ripgrepCache = { pathValue, executable };
1260
+ return executable;
1261
+ }
1160
1262
  /**
1161
- * #287: content-scan one file for `needle`. Skips oversized and likely-binary
1162
- * files up front (a huge perf/robustness win over reading every file whole),
1163
- * then streams it line-by-line so a match is found without buffering the file.
1263
+ * Fast content search using ripgrep's machine-readable stream. Hidden and
1264
+ * ignored paths remain included to preserve the filesystem tool's historical
1265
+ * semantics; callers can still narrow the root path for a smaller search.
1266
+ * Returning null asks the caller to use the portable Node fallback.
1164
1267
  */
1165
- async function scanFileContent(full, needle, remaining) {
1166
- let st;
1268
+ async function searchContentWithRipgrep(executable, rootPath, needle, limit, signal, onProgress) {
1269
+ if (limit <= 0)
1270
+ return { matches: [], truncated: true };
1271
+ const args = [
1272
+ '--json',
1273
+ '--no-config',
1274
+ '--fixed-strings',
1275
+ '--ignore-case',
1276
+ '--hidden',
1277
+ '--no-ignore',
1278
+ '--max-filesize', String(SEARCH_MAX_FILE_BYTES),
1279
+ '--', needle, '.',
1280
+ ];
1281
+ let child;
1167
1282
  try {
1168
- st = await fs.stat(full);
1283
+ child = spawn(executable, args, {
1284
+ cwd: rootPath,
1285
+ windowsHide: true,
1286
+ stdio: ['ignore', 'pipe', 'pipe'],
1287
+ });
1169
1288
  }
1170
1289
  catch {
1171
- return [];
1290
+ return null;
1172
1291
  }
1173
- if (!st.isFile() || st.size > SEARCH_MAX_FILE_BYTES)
1174
- return [];
1175
- // Binary sniff on the first chunk without loading the whole file.
1292
+ activeRipgrepChildren.add(child);
1293
+ let spawnError;
1294
+ let cancelled = false;
1295
+ let stderr = '';
1296
+ child.stderr?.on('data', (chunk) => {
1297
+ if (stderr.length < 8_000)
1298
+ stderr += String(chunk).slice(0, 8_000 - stderr.length);
1299
+ });
1300
+ const closed = new Promise((resolve) => {
1301
+ child.once('error', (error) => {
1302
+ spawnError = error;
1303
+ activeRipgrepChildren.delete(child);
1304
+ resolve(null);
1305
+ });
1306
+ child.once('close', (code) => {
1307
+ activeRipgrepChildren.delete(child);
1308
+ resolve(code);
1309
+ });
1310
+ });
1311
+ const onAbort = () => {
1312
+ cancelled = true;
1313
+ child.kill();
1314
+ };
1315
+ if (signal?.aborted)
1316
+ onAbort();
1317
+ else
1318
+ signal?.addEventListener('abort', onAbort, { once: true });
1319
+ if (!child.stdout) {
1320
+ child.kill();
1321
+ await closed;
1322
+ signal?.removeEventListener('abort', onAbort);
1323
+ return null;
1324
+ }
1325
+ const matches = [];
1326
+ let files = 0;
1327
+ let truncated = false;
1328
+ let parseFailed = false;
1329
+ const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
1176
1330
  try {
1177
- const fh = await fs.open(full, 'r');
1178
- try {
1179
- const probe = Buffer.alloc(Math.min(8000, st.size));
1180
- if (probe.length)
1181
- await fh.read(probe, 0, probe.length, 0);
1182
- if (looksBinary(probe))
1183
- return [];
1331
+ for await (const line of lines) {
1332
+ if (!line)
1333
+ continue;
1334
+ let event;
1335
+ try {
1336
+ event = JSON.parse(line);
1337
+ }
1338
+ catch {
1339
+ parseFailed = true;
1340
+ break;
1341
+ }
1342
+ if (event.type === 'begin') {
1343
+ files++;
1344
+ onProgress?.({ phase: 'searching file contents', files });
1345
+ continue;
1346
+ }
1347
+ if (event.type !== 'match')
1348
+ continue;
1349
+ const relativePath = event.data?.path?.text;
1350
+ const text = event.data?.lines?.text;
1351
+ const lineNumber = event.data?.line_number;
1352
+ if (typeof relativePath !== 'string' || typeof text !== 'string' || typeof lineNumber !== 'number') {
1353
+ continue;
1354
+ }
1355
+ matches.push({
1356
+ path: path.isAbsolute(relativePath) ? path.resolve(relativePath) : path.resolve(rootPath, relativePath),
1357
+ line: lineNumber,
1358
+ text: text.replace(/\r?\n$/, '').slice(0, 400),
1359
+ });
1360
+ onProgress?.({ phase: 'searching file contents', files, matches: matches.length });
1361
+ if (matches.length >= limit) {
1362
+ truncated = true;
1363
+ child.kill();
1364
+ break;
1365
+ }
1184
1366
  }
1185
- finally {
1186
- await fh.close();
1367
+ }
1368
+ finally {
1369
+ lines.close();
1370
+ }
1371
+ if (parseFailed)
1372
+ child.kill();
1373
+ const exitCode = await closed;
1374
+ signal?.removeEventListener('abort', onAbort);
1375
+ if (cancelled)
1376
+ throw new Error('Filesystem search cancelled.');
1377
+ // ripgrep uses 0 for matches and 1 for a clean no-match result. A deliberate
1378
+ // limit kill is also successful because the requested result budget is full.
1379
+ if (spawnError || parseFailed || (!truncated && exitCode !== 0 && exitCode !== 1)) {
1380
+ log.debug('ripgrep search failed; using Node fallback', {
1381
+ error: spawnError?.message,
1382
+ exitCode,
1383
+ stderr: stderr.trim(),
1384
+ });
1385
+ return null;
1386
+ }
1387
+ return { matches, truncated };
1388
+ }
1389
+ /**
1390
+ * Portable one-file scanner. One file descriptor provides the stat, binary
1391
+ * probe, and text stream; the previous implementation opened every candidate
1392
+ * twice in addition to a separate path stat.
1393
+ */
1394
+ async function scanFileContent(full, needle, onMatch, shouldStop, signal) {
1395
+ let handle;
1396
+ let stream;
1397
+ let lines;
1398
+ const onAbort = () => {
1399
+ lines?.close();
1400
+ stream?.destroy(new Error('Filesystem search cancelled.'));
1401
+ };
1402
+ signal?.addEventListener('abort', onAbort, { once: true });
1403
+ try {
1404
+ handle = await fs.open(full, 'r');
1405
+ const st = await handle.stat();
1406
+ if (!st.isFile() || st.size > SEARCH_MAX_FILE_BYTES || shouldStop())
1407
+ return;
1408
+ const probe = Buffer.alloc(Math.min(8_000, st.size));
1409
+ if (probe.length)
1410
+ await handle.read(probe, 0, probe.length, 0);
1411
+ if (looksBinary(probe) || shouldStop())
1412
+ return;
1413
+ const lower = needle.toLowerCase();
1414
+ stream = handle.createReadStream({ encoding: 'utf8', start: 0, autoClose: false });
1415
+ lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
1416
+ let lineNo = 0;
1417
+ for await (const line of lines) {
1418
+ if (shouldStop())
1419
+ break;
1420
+ lineNo++;
1421
+ if (line.toLowerCase().includes(lower)
1422
+ && !onMatch({ path: full, line: lineNo, text: line.slice(0, 400) })) {
1423
+ break;
1424
+ }
1187
1425
  }
1188
1426
  }
1189
1427
  catch {
1190
- return [];
1428
+ // Unreadable or concurrently removed files are skipped, matching the old search behavior.
1191
1429
  }
1192
- const { matches } = await grepFileLines(full, needle, Math.max(1, remaining));
1193
- return matches.map((m) => ({ path: full, line: m.line, text: m.text }));
1194
- }
1195
- async function searchTool(args, roots) {
1196
- const rootPath = await resolvePath(args.path, roots);
1197
- const namePattern = typeof args.namePattern === 'string' ? args.namePattern.toLowerCase() : '';
1198
- const contentPattern = typeof args.content === 'string' ? args.content : '';
1199
- if (!namePattern && !contentPattern) {
1200
- return errorResult('Provide "namePattern" and/or "content" to search for.');
1430
+ finally {
1431
+ signal?.removeEventListener('abort', onAbort);
1432
+ lines?.close();
1433
+ stream?.destroy();
1434
+ await handle?.close().catch(() => undefined);
1201
1435
  }
1202
- const matches = [];
1203
- let truncated = false;
1204
- // First pass: walk the tree collecting NAME matches immediately and gathering
1205
- // the list of candidate files for a (possibly parallel) content scan.
1206
- const files = [];
1207
- async function walk(dir) {
1208
- if (truncated)
1436
+ }
1437
+ /** Dirent traversal plus a bounded, globally-budgeted content worker pool. */
1438
+ async function searchWithNode(rootPath, namePattern, contentPattern, limit, signal, onProgress) {
1439
+ const nameMatches = [];
1440
+ const contentMatches = [];
1441
+ const active = new Set();
1442
+ let nameLimitReached = false;
1443
+ let contentLimitReached = false;
1444
+ let directoriesScanned = 0;
1445
+ let filesScanned = 0;
1446
+ const shouldStopContent = () => signal?.aborted === true || nameLimitReached || contentMatches.length >= limit;
1447
+ const scheduleContentScan = async (full) => {
1448
+ if (!contentPattern || shouldStopContent())
1209
1449
  return;
1210
- let names;
1211
- try {
1212
- names = await fs.readdir(dir);
1450
+ while (active.size >= SEARCH_CONCURRENCY && !shouldStopContent()) {
1451
+ await Promise.race(active);
1213
1452
  }
1214
- catch {
1453
+ if (shouldStopContent())
1215
1454
  return;
1216
- }
1217
- for (const name of names) {
1218
- if (truncated)
1219
- return;
1220
- const full = path.join(dir, name);
1221
- const type = await entryType(full);
1222
- if (namePattern && name.toLowerCase().includes(namePattern)) {
1223
- matches.push({ path: full });
1224
- if (matches.length >= MAX_SEARCH_RESULTS) {
1225
- truncated = true;
1226
- return;
1227
- }
1455
+ const task = scanFileContent(full, contentPattern, (match) => {
1456
+ if (contentMatches.length >= limit)
1457
+ return false;
1458
+ contentMatches.push(match);
1459
+ if (contentMatches.length >= limit) {
1460
+ contentLimitReached = true;
1461
+ return false;
1228
1462
  }
1229
- if (type === 'directory') {
1230
- await walk(full);
1463
+ return !nameLimitReached;
1464
+ }, shouldStopContent, signal).finally(() => active.delete(task));
1465
+ active.add(task);
1466
+ };
1467
+ // Bounded breadth-first traversal. The former recursive walk awaited every
1468
+ // directory serially, which made name-only searches crawl on workspaces with
1469
+ // many small folders even though the filesystem can serve independent reads
1470
+ // concurrently.
1471
+ const directories = [rootPath];
1472
+ while (directories.length > 0
1473
+ && !signal?.aborted
1474
+ && !nameLimitReached
1475
+ && (namePattern || !contentLimitReached)) {
1476
+ const batch = directories.splice(0, SEARCH_DIRECTORY_CONCURRENCY);
1477
+ const listings = await Promise.all(batch.map(async (dir) => {
1478
+ try {
1479
+ return { dir, entries: await fs.readdir(dir, { withFileTypes: true }) };
1231
1480
  }
1232
- else if (type === 'file' && contentPattern) {
1233
- files.push(full);
1481
+ catch {
1482
+ return { dir, entries: [] };
1234
1483
  }
1235
- }
1236
- }
1237
- await walk(rootPath);
1238
- // Second pass: scan file contents with bounded concurrency and early stop.
1239
- if (contentPattern && !truncated) {
1240
- for (let i = 0; i < files.length && !truncated; i += SEARCH_CONCURRENCY) {
1241
- const batch = files.slice(i, i + SEARCH_CONCURRENCY);
1242
- const results = await Promise.all(batch.map((full) => scanFileContent(full, contentPattern, MAX_SEARCH_RESULTS)));
1243
- for (const fileMatches of results) {
1244
- for (const m of fileMatches) {
1245
- matches.push(m);
1246
- if (matches.length >= MAX_SEARCH_RESULTS) {
1247
- truncated = true;
1484
+ }));
1485
+ directoriesScanned += listings.length;
1486
+ onProgress?.({
1487
+ phase: contentPattern ? 'walking directories and scanning files' : 'searching names',
1488
+ directories: directoriesScanned,
1489
+ files: filesScanned,
1490
+ matches: nameMatches.length + contentMatches.length,
1491
+ });
1492
+ for (const { dir, entries } of listings) {
1493
+ for (const entry of entries) {
1494
+ if (signal?.aborted || nameLimitReached || (!namePattern && contentLimitReached))
1495
+ break;
1496
+ const full = path.join(dir, entry.name);
1497
+ if (namePattern && entry.name.toLowerCase().includes(namePattern)) {
1498
+ nameMatches.push({ path: full });
1499
+ onProgress?.({ matches: nameMatches.length + contentMatches.length });
1500
+ if (nameMatches.length >= limit) {
1501
+ nameLimitReached = true;
1248
1502
  break;
1249
1503
  }
1250
1504
  }
1251
- if (truncated)
1252
- break;
1505
+ let isDirectory = entry.isDirectory();
1506
+ let isFile = entry.isFile();
1507
+ // Dirent normally carries the type on Windows/macOS/Linux. Fall back only
1508
+ // for filesystems that report an unknown type; never follow symlinks.
1509
+ if (!isDirectory && !isFile && !entry.isSymbolicLink()) {
1510
+ const type = await entryType(full);
1511
+ isDirectory = type === 'directory';
1512
+ isFile = type === 'file';
1513
+ }
1514
+ if (isDirectory) {
1515
+ directories.push(full);
1516
+ }
1517
+ else if (isFile) {
1518
+ filesScanned++;
1519
+ if (contentPattern && !contentLimitReached)
1520
+ await scheduleContentScan(full);
1521
+ }
1253
1522
  }
1254
1523
  }
1255
1524
  }
1256
- return dualResult({ matches, truncated });
1525
+ await Promise.all(active);
1526
+ onProgress?.({
1527
+ directories: directoriesScanned,
1528
+ files: filesScanned,
1529
+ matches: nameMatches.length + contentMatches.length,
1530
+ }, true);
1531
+ const matches = [...nameMatches, ...contentMatches].slice(0, limit);
1532
+ return {
1533
+ matches,
1534
+ truncated: nameLimitReached || contentLimitReached || nameMatches.length + contentMatches.length >= limit,
1535
+ };
1536
+ }
1537
+ async function searchTool(args, roots, context) {
1538
+ const signal = context?.signal;
1539
+ if (signal?.aborted)
1540
+ throw new Error('Filesystem search cancelled.');
1541
+ const rootPath = await resolvePath(args.path, roots);
1542
+ const namePattern = typeof args.namePattern === 'string' ? args.namePattern.toLowerCase() : '';
1543
+ const contentPattern = typeof args.content === 'string' ? args.content : '';
1544
+ if (!namePattern && !contentPattern) {
1545
+ return errorResult('Provide "namePattern" and/or "content" to search for.');
1546
+ }
1547
+ const progress = createSearchProgressReporter(context);
1548
+ progress.update({ phase: 'starting filesystem search' }, true);
1549
+ try {
1550
+ const ripgrep = contentPattern ? await resolveRipgrepExecutable() : null;
1551
+ if (!ripgrep) {
1552
+ const result = await searchWithNode(rootPath, namePattern, contentPattern, MAX_SEARCH_RESULTS, signal, progress.update);
1553
+ if (signal?.aborted)
1554
+ throw new Error('Filesystem search cancelled.');
1555
+ progress.update({ phase: 'filesystem search complete', matches: result.matches.length }, true);
1556
+ return dualResult(result);
1557
+ }
1558
+ // Name matches retain their historical priority in the combined response.
1559
+ const names = namePattern
1560
+ ? await searchWithNode(rootPath, namePattern, '', MAX_SEARCH_RESULTS, signal, progress.update)
1561
+ : { matches: [], truncated: false };
1562
+ if (signal?.aborted)
1563
+ throw new Error('Filesystem search cancelled.');
1564
+ if (names.truncated)
1565
+ return dualResult(names);
1566
+ const remaining = MAX_SEARCH_RESULTS - names.matches.length;
1567
+ const content = await searchContentWithRipgrep(ripgrep, rootPath, contentPattern, remaining, signal, progress.update);
1568
+ if (content) {
1569
+ progress.update({
1570
+ phase: 'filesystem search complete',
1571
+ matches: names.matches.length + content.matches.length,
1572
+ }, true);
1573
+ return dualResult({
1574
+ matches: [...names.matches, ...content.matches],
1575
+ truncated: content.truncated || names.matches.length + content.matches.length >= MAX_SEARCH_RESULTS,
1576
+ });
1577
+ }
1578
+ const fallback = await searchWithNode(rootPath, '', contentPattern, remaining, signal, progress.update);
1579
+ if (signal?.aborted)
1580
+ throw new Error('Filesystem search cancelled.');
1581
+ return dualResult({
1582
+ matches: [...names.matches, ...fallback.matches],
1583
+ truncated: fallback.truncated || names.matches.length + fallback.matches.length >= MAX_SEARCH_RESULTS,
1584
+ });
1585
+ }
1586
+ finally {
1587
+ await progress.stop();
1588
+ }
1257
1589
  }
1258
1590
  async function getFileInfoTool(args, roots) {
1259
1591
  const target = await resolvePath(args.path, roots);
@@ -1289,7 +1621,14 @@ async function deleteTool(args, roots) {
1289
1621
  async function getAllowedDirectoriesTool(roots) {
1290
1622
  return dualResult({ directories: roots });
1291
1623
  }
1292
- export async function filesystemCallTool(toolName, args, callerNodeId) {
1624
+ export async function filesystemCallTool(toolName, args, callerNodeId, contextOrSignal) {
1625
+ // Backwards-compatible direct-call shape: older callers/tests passed the
1626
+ // AbortSignal itself as argument four.
1627
+ const context = contextOrSignal
1628
+ ? ('aborted' in contextOrSignal
1629
+ ? { signal: contextOrSignal }
1630
+ : contextOrSignal)
1631
+ : undefined;
1293
1632
  try {
1294
1633
  // MCP App launcher (#97): pure UI trigger — returns immediately without
1295
1634
  // touching the filesystem. The app renders in chat; the user's pick returns
@@ -1310,7 +1649,7 @@ export async function filesystemCallTool(toolName, args, callerNodeId) {
1310
1649
  case 'dir_tree':
1311
1650
  return await dirTreeTool(args, roots);
1312
1651
  case 'search':
1313
- return await searchTool(args, roots);
1652
+ return await searchTool(args, roots, context);
1314
1653
  case 'get_file_info':
1315
1654
  return await getFileInfoTool(args, roots);
1316
1655
  case 'create_directory':