@j0hanz/filesystem-mcp 1.7.2 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,3 +2,45 @@ export declare function needsStatsForSort(sortBy: string): boolean;
2
2
  export declare function withOptionalStoppedReason<T extends object, R extends string>(summary: T, stoppedReason: R | undefined): T | (T & {
3
3
  stoppedReason: R;
4
4
  });
5
+ export interface DirentLike {
6
+ isDirectory(): boolean;
7
+ isFile(): boolean;
8
+ isSymbolicLink(): boolean;
9
+ }
10
+ export type EntryType = 'file' | 'directory' | 'symlink' | 'other';
11
+ export interface IndexedValue<T> {
12
+ index: number;
13
+ value: T;
14
+ }
15
+ export interface IndexedError {
16
+ index: number;
17
+ error: Error;
18
+ }
19
+ export declare function resolveEntryType(dirent: DirentLike): EntryType;
20
+ export declare function resolveStopReason<R extends string>(options: {
21
+ signal: AbortSignal;
22
+ current: number;
23
+ max: number;
24
+ abortedReason: R;
25
+ maxReason: R;
26
+ }): R | undefined;
27
+ export declare function compareStringValues(left?: string, right?: string): number;
28
+ export declare function compareOptionalNumberDesc(left: number | undefined, right: number | undefined, tieBreak: () => number): number;
29
+ export declare function stableSortByDerivedString<T>(items: T[], derive: (item: T) => string, tieBreak: (left: T, right: T) => number): void;
30
+ export declare function applyIndexedValues<T>(output: T[], results: readonly IndexedValue<T>[]): void;
31
+ export declare function applyIndexedErrors<T>(options: {
32
+ output: T[];
33
+ errors: readonly IndexedError[];
34
+ resolveIndex: (failureIndex: number) => number | undefined;
35
+ buildValue: (resolvedIndex: number, error: Error) => T;
36
+ }): void;
37
+ export interface EntryAccessDependencies {
38
+ normalizePath: (inputPath: string) => string;
39
+ isPathWithinDirectories: (normalizedPath: string, rootDirectories: readonly string[]) => boolean;
40
+ isSensitivePath: (requestedPath: string, resolvedPath: string) => boolean;
41
+ validateSymlinkPath: (inputPath: string, signal: AbortSignal) => Promise<{
42
+ requestedPath: string;
43
+ resolvedPath: string;
44
+ }>;
45
+ }
46
+ export declare function isEntryAccessibleByType(entryPath: string, entryType: EntryType, rootDirectories: readonly string[], signal: AbortSignal, deps: EntryAccessDependencies): Promise<boolean>;
@@ -1,9 +1,96 @@
1
1
  export function needsStatsForSort(sortBy) {
2
2
  return sortBy === 'size' || sortBy === 'modified';
3
3
  }
4
+ const collator = new Intl.Collator(undefined, { numeric: true });
4
5
  export function withOptionalStoppedReason(summary, stoppedReason) {
5
6
  if (stoppedReason === undefined) {
6
7
  return summary;
7
8
  }
8
9
  return { ...summary, stoppedReason };
9
10
  }
11
+ export function resolveEntryType(dirent) {
12
+ if (dirent.isSymbolicLink())
13
+ return 'symlink';
14
+ if (dirent.isDirectory())
15
+ return 'directory';
16
+ if (dirent.isFile())
17
+ return 'file';
18
+ return 'other';
19
+ }
20
+ export function resolveStopReason(options) {
21
+ if (options.signal.aborted)
22
+ return options.abortedReason;
23
+ if (options.current >= options.max)
24
+ return options.maxReason;
25
+ return undefined;
26
+ }
27
+ export function compareStringValues(left, right) {
28
+ return collator.compare(left ?? '', right ?? '');
29
+ }
30
+ export function compareOptionalNumberDesc(left, right, tieBreak) {
31
+ const diff = (right ?? 0) - (left ?? 0);
32
+ if (diff !== 0)
33
+ return diff;
34
+ return tieBreak();
35
+ }
36
+ export function stableSortByDerivedString(items, derive, tieBreak) {
37
+ const decorated = [];
38
+ for (let index = 0; index < items.length; index += 1) {
39
+ const item = items[index];
40
+ if (item === undefined)
41
+ continue;
42
+ decorated.push({
43
+ item,
44
+ derived: derive(item),
45
+ index,
46
+ });
47
+ }
48
+ decorated.sort((left, right) => {
49
+ const derivedCompare = compareStringValues(left.derived, right.derived);
50
+ if (derivedCompare !== 0)
51
+ return derivedCompare;
52
+ const tiedCompare = tieBreak(left.item, right.item);
53
+ if (tiedCompare !== 0)
54
+ return tiedCompare;
55
+ return left.index - right.index;
56
+ });
57
+ for (let index = 0; index < decorated.length; index += 1) {
58
+ const entry = decorated[index];
59
+ if (!entry)
60
+ continue;
61
+ items[index] = entry.item;
62
+ }
63
+ }
64
+ export function applyIndexedValues(output, results) {
65
+ for (const result of results) {
66
+ if (result.index < 0 || result.index >= output.length)
67
+ continue;
68
+ output[result.index] = result.value;
69
+ }
70
+ }
71
+ export function applyIndexedErrors(options) {
72
+ for (const failure of options.errors) {
73
+ const resolvedIndex = options.resolveIndex(failure.index);
74
+ if (resolvedIndex === undefined)
75
+ continue;
76
+ if (resolvedIndex < 0 || resolvedIndex >= options.output.length)
77
+ continue;
78
+ options.output[resolvedIndex] = options.buildValue(resolvedIndex, failure.error);
79
+ }
80
+ }
81
+ export async function isEntryAccessibleByType(entryPath, entryType, rootDirectories, signal, deps) {
82
+ if (entryType !== 'symlink') {
83
+ const normalizedPath = deps.normalizePath(entryPath);
84
+ if (!deps.isPathWithinDirectories(normalizedPath, rootDirectories)) {
85
+ return false;
86
+ }
87
+ return !deps.isSensitivePath(entryPath, normalizedPath);
88
+ }
89
+ try {
90
+ const validated = await deps.validateSymlinkPath(entryPath, signal);
91
+ return !deps.isSensitivePath(validated.requestedPath, validated.resolvedPath);
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
@@ -5,6 +5,7 @@ import { isAbortError } from '../errors.js';
5
5
  import { assertNotAborted, getFileType, isHidden, processInParallel, withAbort, } from '../fs-helpers.js';
6
6
  import { assertAllowedFileAccess } from '../path-policy.js';
7
7
  import { validateExistingPathDetailed } from '../path-validation.js';
8
+ import { applyIndexedErrors, applyIndexedValues } from './common.js';
8
9
  const PERM_STRINGS = [
9
10
  '---',
10
11
  '--x',
@@ -95,23 +96,6 @@ async function readFileInfoInParallel(paths, options) {
95
96
  return { index, value };
96
97
  }, PARALLEL_CONCURRENCY, options.signal);
97
98
  }
98
- function applyResults(output, results) {
99
- for (const result of results) {
100
- output[result.index] = result.value;
101
- }
102
- }
103
- function applyErrors(output, errors, paths) {
104
- for (const failure of errors) {
105
- const { index } = failure;
106
- if (!isValidOutputIndex(index, output.length))
107
- continue;
108
- const filePath = paths[index] ?? UNKNOWN_PATH;
109
- output[index] = { path: filePath, error: failure.error.message };
110
- }
111
- }
112
- function isValidOutputIndex(index, length) {
113
- return index >= 0 && index < length;
114
- }
115
99
  function calculateSummary(results) {
116
100
  let succeeded = 0;
117
101
  let failed = 0;
@@ -140,8 +124,18 @@ export async function getMultipleFileInfo(paths, options = {}) {
140
124
  output[index] = { path: paths[index] ?? UNKNOWN_PATH };
141
125
  }
142
126
  const { results, errors } = await readFileInfoInParallel(paths, options);
143
- applyResults(output, results);
144
- applyErrors(output, errors, paths);
127
+ applyIndexedValues(output, results);
128
+ applyIndexedErrors({
129
+ output,
130
+ errors,
131
+ resolveIndex: (failureIndex) => failureIndex >= 0 && failureIndex < output.length
132
+ ? failureIndex
133
+ : undefined,
134
+ buildValue: (resolvedIndex, error) => ({
135
+ path: paths[resolvedIndex] ?? UNKNOWN_PATH,
136
+ error: error.message,
137
+ }),
138
+ });
145
139
  return {
146
140
  results: output,
147
141
  summary: calculateSummary(output),
@@ -1,10 +1,5 @@
1
1
  import type { Stats } from 'node:fs';
2
- interface DirentLike {
3
- isDirectory(): boolean;
4
- isFile(): boolean;
5
- isSymbolicLink(): boolean;
6
- }
7
- export declare function resolveEntryType(dirent: DirentLike): 'file' | 'directory' | 'symlink' | 'other';
2
+ import type { DirentLike } from './common.js';
8
3
  interface GlobEntry {
9
4
  path: string;
10
5
  relativePath?: string;
@@ -4,15 +4,6 @@ import { glob as fsGlob } from 'node:fs/promises';
4
4
  import { getToolContextSnapshot, publishOpsTraceEnd, publishOpsTraceError, publishOpsTraceStart, shouldPublishOpsTrace, startPerfMeasure, } from '../observability.js';
5
5
  import { toPosixPath } from '../path-format.js';
6
6
  import { isRecord } from '../type-guards.js';
7
- export function resolveEntryType(dirent) {
8
- if (dirent.isDirectory())
9
- return 'directory';
10
- if (dirent.isSymbolicLink())
11
- return 'symlink';
12
- if (dirent.isFile())
13
- return 'file';
14
- return 'other';
15
- }
16
7
  const GLOB_MAGIC_RE = /[*?[\]{}!]/u;
17
8
  const DEFAULT_MAX_HIDDEN_DEPTH = 10;
18
9
  const GLOB_BATCH_CONCURRENCY = 64;
@@ -1,11 +1,11 @@
1
1
  import * as fsp from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { DEFAULT_LIST_MAX_ENTRIES, DEFAULT_MAX_DEPTH, DEFAULT_SEARCH_TIMEOUT_MS, PARALLEL_CONCURRENCY, } from '../constants.js';
4
- import { createTimedAbortSignal, processInParallel, withAbort, } from '../fs-helpers.js';
4
+ import { createTimedAbortSignal, isHidden, processInParallel, withAbort, } from '../fs-helpers.js';
5
5
  import { isSensitivePath } from '../path-policy.js';
6
6
  import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
7
- import { needsStatsForSort, withOptionalStoppedReason } from './common.js';
8
- import { globEntries, resolveEntryType } from './glob-engine.js';
7
+ import { isEntryAccessibleByType, needsStatsForSort, resolveEntryType, resolveStopReason, withOptionalStoppedReason, } from './common.js';
8
+ import { globEntries } from './glob-engine.js';
9
9
  function normalizePattern(pattern) {
10
10
  if (!pattern || pattern.length === 0)
11
11
  return undefined;
@@ -40,18 +40,11 @@ function resolveMaxDepth(normalized) {
40
40
  }
41
41
  return normalized.maxDepth;
42
42
  }
43
- function getStopReason(signal, acceptedCount, maxEntries) {
44
- if (signal.aborted)
45
- return 'aborted';
46
- if (acceptedCount >= maxEntries)
47
- return 'maxEntries';
48
- return undefined;
49
- }
50
43
  async function* readDirectoryEntries(basePath, normalized, needsStats, signal) {
51
44
  const dirents = await withAbort(fsp.readdir(basePath, { withFileTypes: true }), signal);
52
45
  const entries = [];
53
46
  for (const dirent of dirents) {
54
- if (!normalized.includeHidden && dirent.name.startsWith('.')) {
47
+ if (!normalized.includeHidden && isHidden(dirent.name)) {
55
48
  continue;
56
49
  }
57
50
  entries.push({ dirent, entryPath: path.join(basePath, dirent.name) });
@@ -149,32 +142,6 @@ function trackSymlink(entryType, includeSymlinkTargets, counters) {
149
142
  counters.symlinksNotFollowed += 1;
150
143
  }
151
144
  }
152
- async function isEntryAccessible(entryPath, entryType, basePathDirectories, signal, counters) {
153
- if (entryType !== 'symlink') {
154
- const normalized = normalizePath(entryPath);
155
- if (!isPathWithinDirectories(normalized, basePathDirectories)) {
156
- counters.skippedInaccessible += 1;
157
- return false;
158
- }
159
- if (isSensitivePath(entryPath, normalized)) {
160
- counters.skippedInaccessible += 1;
161
- return false;
162
- }
163
- return true;
164
- }
165
- try {
166
- const validated = await validateExistingPathDetailed(entryPath, signal);
167
- if (isSensitivePath(validated.requestedPath, validated.resolvedPath)) {
168
- counters.skippedInaccessible += 1;
169
- return false;
170
- }
171
- return true;
172
- }
173
- catch {
174
- counters.skippedInaccessible += 1;
175
- return false;
176
- }
177
- }
178
145
  function appendEntry(entry, entryType, symlinkTarget, ctx) {
179
146
  updateTotals(entryType, ctx.totals);
180
147
  ctx.entries.push(buildDirectoryEntry(ctx.basePath, entry, entryType, ctx.needsStats, symlinkTarget));
@@ -214,6 +181,12 @@ async function collectEntries(basePath, normalized, signal, needsStats, maxDepth
214
181
  const totals = { files: 0, directories: 0 };
215
182
  const counters = { skippedInaccessible: 0, symlinksNotFollowed: 0 };
216
183
  const basePathDirectories = [basePath];
184
+ const accessDeps = {
185
+ normalizePath,
186
+ isPathWithinDirectories,
187
+ isSensitivePath,
188
+ validateSymlinkPath: validateExistingPathDetailed,
189
+ };
217
190
  let truncated = false;
218
191
  let stoppedReason;
219
192
  const pending = [];
@@ -232,7 +205,13 @@ async function collectEntries(basePath, normalized, signal, needsStats, maxDepth
232
205
  entries,
233
206
  };
234
207
  for await (const entry of stream) {
235
- const stopReason = getStopReason(signal, acceptedCount, normalized.maxEntries);
208
+ const stopReason = resolveStopReason({
209
+ signal,
210
+ current: acceptedCount,
211
+ max: normalized.maxEntries,
212
+ abortedReason: 'aborted',
213
+ maxReason: 'maxEntries',
214
+ });
236
215
  if (stopReason) {
237
216
  truncated = true;
238
217
  stoppedReason = stopReason;
@@ -240,8 +219,9 @@ async function collectEntries(basePath, normalized, signal, needsStats, maxDepth
240
219
  }
241
220
  const entryType = resolveEntryType(entry.dirent);
242
221
  trackSymlink(entryType, normalized.includeSymlinkTargets, counters);
243
- const accessible = await isEntryAccessible(entry.path, entryType, basePathDirectories, signal, counters);
222
+ const accessible = await isEntryAccessibleByType(entry.path, entryType, basePathDirectories, signal, accessDeps);
244
223
  if (!accessible) {
224
+ counters.skippedInaccessible += 1;
245
225
  continue;
246
226
  }
247
227
  acceptedCount += 1;
@@ -2,6 +2,7 @@ import * as fsp from 'node:fs/promises';
2
2
  import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from '../constants.js';
3
3
  import { processInParallel, readFile, readFileWithStats, withAbort, } from '../fs-helpers.js';
4
4
  import { validateExistingPath } from '../path-validation.js';
5
+ import { applyIndexedErrors, applyIndexedValues } from './common.js';
5
6
  const UNKNOWN_PATH = '(unknown)';
6
7
  function estimateReadSize(stats, maxSize) {
7
8
  // `readFile`/`readFileWithStats` are always invoked with a `maxSize` cap, so the
@@ -173,11 +174,6 @@ function buildOutput(filePaths) {
173
174
  }
174
175
  return output;
175
176
  }
176
- function applyResults(output, results) {
177
- for (const result of results) {
178
- output[result.index] = result.value;
179
- }
180
- }
181
177
  function resolveErrorOriginalIndex(failureIndex, filesToProcess, totalInputFiles) {
182
178
  // processInParallel implementations vary: some return error indices relative to
183
179
  // the submitted batch (filesToProcess), others may forward the task/index.
@@ -192,18 +188,6 @@ function resolveErrorOriginalIndex(failureIndex, filesToProcess, totalInputFiles
192
188
  }
193
189
  return undefined;
194
190
  }
195
- function applyErrors(output, errors, filesToProcess, filePaths) {
196
- for (const failure of errors) {
197
- const originalIndex = resolveErrorOriginalIndex(failure.index, filesToProcess, filePaths.length);
198
- if (originalIndex === undefined)
199
- continue;
200
- const filePath = filePaths[originalIndex] ?? UNKNOWN_PATH;
201
- output[originalIndex] = {
202
- path: filePath,
203
- error: failure.error.message,
204
- };
205
- }
206
- }
207
191
  function buildFilesToProcess(filePaths, validated, skippedBudget) {
208
192
  const filesToProcess = [];
209
193
  for (let index = 0; index < filePaths.length; index += 1) {
@@ -253,8 +237,16 @@ export async function readMultipleFiles(filePaths, options = {}) {
253
237
  const { skippedBudget, validated } = await collectFileBudget(filePaths, normalized.maxTotalSize, normalized.maxSize, signal);
254
238
  const filesToProcess = buildFilesToProcess(filePaths, validated, skippedBudget);
255
239
  const { results, errors } = await readFilesInParallel(filesToProcess, normalized, signal, options.onReadComplete);
256
- applyResults(output, results);
257
- applyErrors(output, errors, filesToProcess, filePaths);
240
+ applyIndexedValues(output, results);
241
+ applyIndexedErrors({
242
+ output,
243
+ errors,
244
+ resolveIndex: (failureIndex) => resolveErrorOriginalIndex(failureIndex, filesToProcess, filePaths.length),
245
+ buildValue: (resolvedIndex, error) => ({
246
+ path: filePaths[resolvedIndex] ?? UNKNOWN_PATH,
247
+ error: error.message,
248
+ }),
249
+ });
258
250
  applySkippedBudget(output, skippedBudget, filePaths, normalized.maxTotalSize);
259
251
  return output;
260
252
  }
@@ -291,6 +291,25 @@ async function scanFileResolved(resolvedPath, requestedPath, matcher, options, s
291
291
  await handle.close();
292
292
  }
293
293
  }
294
+ function buildScanFileOptions(opts) {
295
+ return {
296
+ maxFileSize: opts.maxFileSize,
297
+ skipBinary: opts.skipBinary,
298
+ contextLines: opts.contextLines,
299
+ };
300
+ }
301
+ function applyScanOutcome(summary, outcome) {
302
+ if (outcome.matched)
303
+ summary.filesMatched++;
304
+ if (outcome.skippedBinary)
305
+ summary.skippedBinary++;
306
+ if (outcome.skippedTooLarge)
307
+ summary.skippedTooLarge++;
308
+ }
309
+ function markTruncated(summary, reason) {
310
+ summary.truncated = true;
311
+ summary.stoppedReason = reason;
312
+ }
294
313
  function createScanSummary() {
295
314
  return {
296
315
  filesScanned: 0,
@@ -474,32 +493,21 @@ function getPool() {
474
493
  async function executeSequential(files, pattern, opts, signal, summary) {
475
494
  const matches = [];
476
495
  const matcher = buildMatcher(pattern, opts);
477
- const scanOpts = {
478
- maxFileSize: opts.maxFileSize,
479
- skipBinary: opts.skipBinary,
480
- contextLines: opts.contextLines,
481
- };
496
+ const scanOpts = buildScanFileOptions(opts);
482
497
  for await (const file of files) {
483
498
  if (signal.aborted) {
484
- summary.truncated = true;
485
- summary.stoppedReason = 'timeout';
499
+ markTruncated(summary, 'timeout');
486
500
  break;
487
501
  }
488
502
  if (matches.length >= opts.maxResults) {
489
- summary.truncated = true;
490
- summary.stoppedReason = 'maxResults';
503
+ markTruncated(summary, 'maxResults');
491
504
  break;
492
505
  }
493
506
  try {
494
507
  assertAllowedFileAccess(file.requestedPath, file.resolvedPath);
495
508
  const remaining = opts.maxResults - matches.length;
496
509
  const result = await scanFileResolved(file.resolvedPath, file.requestedPath, matcher, scanOpts, signal, remaining);
497
- if (result.matched)
498
- summary.filesMatched++;
499
- if (result.skippedBinary)
500
- summary.skippedBinary++;
501
- if (result.skippedTooLarge)
502
- summary.skippedTooLarge++;
510
+ applyScanOutcome(summary, result);
503
511
  matches.push(...result.matches);
504
512
  }
505
513
  catch {
@@ -542,12 +550,7 @@ function processScanResult(winner, summary, matches, maxResults) {
542
550
  }
543
551
  if (winner.result) {
544
552
  const res = winner.result;
545
- if (res.matched)
546
- summary.filesMatched++;
547
- if (res.skippedBinary)
548
- summary.skippedBinary++;
549
- if (res.skippedTooLarge)
550
- summary.skippedTooLarge++;
553
+ applyScanOutcome(summary, res);
551
554
  const remaining = maxResults - matches.length;
552
555
  if (remaining > 0 && res.matches.length > 0) {
553
556
  const take = Math.min(remaining, res.matches.length);
@@ -582,11 +585,7 @@ async function waitForWinner(pending) {
582
585
  async function executeParallel(files, pattern, opts, signal, summary) {
583
586
  const pool = getPool();
584
587
  const matches = [];
585
- const scanOpts = {
586
- maxFileSize: opts.maxFileSize,
587
- skipBinary: opts.skipBinary,
588
- contextLines: opts.contextLines,
589
- };
588
+ const scanOpts = buildScanFileOptions(opts);
590
589
  const matcherOpts = {
591
590
  caseSensitive: opts.caseSensitive,
592
591
  wholeWord: opts.wholeWord,
@@ -596,8 +595,7 @@ async function executeParallel(files, pattern, opts, signal, summary) {
596
595
  const iterator = files[Symbol.asyncIterator]();
597
596
  let exhausted = false;
598
597
  const onAbort = () => {
599
- summary.truncated = true;
600
- summary.stoppedReason = 'timeout';
598
+ markTruncated(summary, 'timeout');
601
599
  for (const t of pending)
602
600
  t.cancel();
603
601
  };
@@ -629,12 +627,10 @@ async function executeParallel(files, pattern, opts, signal, summary) {
629
627
  }
630
628
  // Update summary truncation
631
629
  if (signal.aborted) {
632
- summary.truncated = true;
633
- summary.stoppedReason = 'timeout';
630
+ markTruncated(summary, 'timeout');
634
631
  }
635
632
  else if (matches.length >= opts.maxResults) {
636
- summary.truncated = true;
637
- summary.stoppedReason = 'maxResults';
633
+ markTruncated(summary, 'maxResults');
638
634
  }
639
635
  return matches;
640
636
  }
@@ -649,6 +645,80 @@ export async function scanFileInWorker(resolvedPath, requestedPath, matcher, opt
649
645
  skippedTooLarge: res.skippedTooLarge,
650
646
  };
651
647
  }
648
+ async function searchSingleFile(details, opts, pattern, signal) {
649
+ const summary = createScanSummary();
650
+ summary.filesScanned = 1;
651
+ const matcher = buildMatcher(pattern, opts);
652
+ const result = await scanFileResolved(details.resolvedPath, details.requestedPath, matcher, {
653
+ ...buildScanFileOptions(opts),
654
+ }, signal, opts.maxResults);
655
+ if (result.matched)
656
+ summary.filesMatched = 1;
657
+ return buildSearchResult(path.dirname(details.resolvedPath), pattern, opts.filePattern, result.matches, summary);
658
+ }
659
+ async function searchDirectory(details, opts, pattern, signal, onProgress) {
660
+ const root = await validateExistingDirectory(details.resolvedPath, signal);
661
+ const rootDirectories = [root];
662
+ const stream = globEntries({
663
+ cwd: root,
664
+ pattern: opts.filePattern,
665
+ excludePatterns: opts.excludePatterns,
666
+ includeHidden: opts.includeHidden,
667
+ baseNameMatch: opts.baseNameMatch,
668
+ caseSensitiveMatch: opts.caseSensitiveFileMatch,
669
+ followSymbolicLinks: false,
670
+ onlyFiles: true,
671
+ stats: false,
672
+ suppressErrors: true,
673
+ });
674
+ async function* fileGenerator() {
675
+ let scanned = 0;
676
+ for await (const entry of stream) {
677
+ if (signal.aborted)
678
+ break;
679
+ if (scanned >= opts.maxFilesScanned)
680
+ break;
681
+ if (!entry.dirent.isFile())
682
+ continue;
683
+ const normalized = normalizePath(entry.path);
684
+ if (!isPathWithinDirectories(normalized, rootDirectories))
685
+ continue;
686
+ if (isSensitivePath(entry.path, normalized))
687
+ continue;
688
+ scanned++;
689
+ reportSearchProgress(onProgress, scanned, opts.maxFilesScanned);
690
+ yield { resolvedPath: normalized, requestedPath: entry.path };
691
+ }
692
+ reportSearchProgress(onProgress, scanned, opts.maxFilesScanned, true);
693
+ }
694
+ const summary = createScanSummary();
695
+ const resolvedStream = fileGenerator();
696
+ async function* countingStream() {
697
+ for await (const f of resolvedStream) {
698
+ summary.filesScanned++;
699
+ yield f;
700
+ }
701
+ if (summary.filesScanned >= opts.maxFilesScanned) {
702
+ summary.truncated = true;
703
+ summary.stoppedReason = 'maxFiles';
704
+ }
705
+ }
706
+ const matcherOpts = {
707
+ caseSensitive: opts.caseSensitive,
708
+ wholeWord: opts.wholeWord,
709
+ isLiteral: opts.isLiteral,
710
+ };
711
+ validatePattern(pattern, matcherOpts);
712
+ const matches = shouldUseWorkers()
713
+ ? await executeParallel(countingStream(), pattern, opts, signal, summary)
714
+ : await executeSequential(countingStream(), pattern, opts, signal, summary);
715
+ return buildSearchResult(root, pattern, opts.filePattern, matches, summary);
716
+ }
717
+ function buildTimeoutSearchResult(basePath, pattern, filePattern) {
718
+ const timeoutSummary = createScanSummary();
719
+ markTruncated(timeoutSummary, 'timeout');
720
+ return buildSearchResult(basePath, pattern, filePattern, [], timeoutSummary);
721
+ }
652
722
  export async function searchContent(basePath, pattern, options = {}) {
653
723
  if (!basePath.trim())
654
724
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'basePath required');
@@ -659,94 +729,17 @@ export async function searchContent(basePath, pattern, options = {}) {
659
729
  try {
660
730
  const details = await validateExistingPathDetailed(basePath, signal);
661
731
  const stats = await withAbort(fsp.stat(details.resolvedPath), signal);
662
- // Check if simple file scan
663
732
  if (stats.isFile()) {
664
- const summary = createScanSummary();
665
- summary.filesScanned = 1;
666
- // Single file execution
667
- const matcher = buildMatcher(pattern, opts);
668
- const result = await scanFileResolved(details.resolvedPath, details.requestedPath, matcher, {
669
- maxFileSize: opts.maxFileSize,
670
- skipBinary: opts.skipBinary,
671
- contextLines: opts.contextLines,
672
- }, signal, opts.maxResults);
673
- if (result.matched)
674
- summary.filesMatched = 1;
675
- return buildSearchResult(path.dirname(details.resolvedPath), pattern, opts.filePattern, result.matches, summary);
733
+ return await searchSingleFile(details, opts, pattern, signal);
676
734
  }
677
735
  if (!stats.isDirectory()) {
678
- throw new McpError(ErrorCode.E_INVALID_INPUT, `Path must be file or directory`, basePath);
736
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Path must be file or directory', basePath);
679
737
  }
680
- const root = await validateExistingDirectory(details.resolvedPath, signal);
681
- const rootDirectories = [root];
682
- // Glob
683
- const stream = globEntries({
684
- cwd: root,
685
- pattern: opts.filePattern,
686
- excludePatterns: opts.excludePatterns,
687
- includeHidden: opts.includeHidden,
688
- baseNameMatch: opts.baseNameMatch,
689
- caseSensitiveMatch: opts.caseSensitiveFileMatch,
690
- followSymbolicLinks: false,
691
- onlyFiles: true,
692
- stats: false,
693
- suppressErrors: true,
694
- });
695
- // Generator adapter to resolve paths
696
- async function* fileGenerator() {
697
- let scanned = 0;
698
- for await (const entry of stream) {
699
- if (signal.aborted)
700
- break;
701
- if (scanned >= opts.maxFilesScanned)
702
- break;
703
- if (!entry.dirent.isFile())
704
- continue;
705
- // Helper to resolve
706
- // We duplicate simple resolution logic to keep it fast
707
- const normalized = normalizePath(entry.path);
708
- if (!isPathWithinDirectories(normalized, rootDirectories))
709
- continue;
710
- if (isSensitivePath(entry.path, normalized))
711
- continue;
712
- scanned++;
713
- reportSearchProgress(options.onProgress, scanned, opts.maxFilesScanned);
714
- yield { resolvedPath: normalized, requestedPath: entry.path };
715
- }
716
- reportSearchProgress(options.onProgress, scanned, opts.maxFilesScanned, true);
717
- }
718
- // Choose Strategy
719
- // We recreate summary to track actual scans
720
- const summary = createScanSummary();
721
- const resolvedStream = fileGenerator();
722
- // Wrap generator to count scanned files in summary
723
- async function* countingStream() {
724
- for await (const f of resolvedStream) {
725
- summary.filesScanned++;
726
- yield f;
727
- }
728
- if (summary.filesScanned >= opts.maxFilesScanned) {
729
- summary.truncated = true;
730
- summary.stoppedReason = 'maxFiles';
731
- }
732
- }
733
- const matcherOpts = {
734
- caseSensitive: opts.caseSensitive,
735
- wholeWord: opts.wholeWord,
736
- isLiteral: opts.isLiteral,
737
- };
738
- validatePattern(pattern, matcherOpts);
739
- const matches = shouldUseWorkers()
740
- ? await executeParallel(countingStream(), pattern, opts, signal, summary)
741
- : await executeSequential(countingStream(), pattern, opts, signal, summary);
742
- return buildSearchResult(root, pattern, opts.filePattern, matches, summary);
738
+ return await searchDirectory(details, opts, pattern, signal, options.onProgress);
743
739
  }
744
740
  catch (error) {
745
741
  if (isTimeoutLikeError(error)) {
746
- const timeoutSummary = createScanSummary();
747
- timeoutSummary.truncated = true;
748
- timeoutSummary.stoppedReason = 'timeout';
749
- return buildSearchResult(basePath, pattern, opts.filePattern, [], timeoutSummary);
742
+ return buildTimeoutSearchResult(basePath, pattern, opts.filePattern);
750
743
  }
751
744
  throw error;
752
745
  }