@j0hanz/filesystem-mcp 1.7.2 → 1.8.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/lib/file-operations/common.d.ts +42 -0
- package/dist/lib/file-operations/common.js +87 -0
- package/dist/lib/file-operations/file-info.js +13 -19
- package/dist/lib/file-operations/glob-engine.d.ts +1 -6
- package/dist/lib/file-operations/glob-engine.js +0 -9
- package/dist/lib/file-operations/glob-helpers.d.ts +18 -0
- package/dist/lib/file-operations/glob-helpers.js +23 -0
- package/dist/lib/file-operations/list-directory.js +22 -46
- package/dist/lib/file-operations/read-multiple-files.js +11 -19
- package/dist/lib/file-operations/search-content.d.ts +1 -8
- package/dist/lib/file-operations/search-content.js +126 -204
- package/dist/lib/file-operations/search-files.js +48 -94
- package/dist/lib/file-operations/search-matcher.d.ts +10 -0
- package/dist/lib/file-operations/search-matcher.js +72 -0
- package/dist/lib/file-operations/search-worker.js +3 -1
- package/dist/lib/file-operations/tree.d.ts +2 -2
- package/dist/lib/file-operations/tree.js +26 -42
- package/dist/lib/fs-helpers.d.ts +1 -0
- package/dist/lib/fs-helpers.js +9 -0
- package/dist/lib/option-utils.d.ts +3 -0
- package/dist/lib/option-utils.js +15 -0
- package/dist/lib/path-validation.d.ts +1 -0
- package/dist/lib/path-validation.js +7 -0
- package/dist/lib/progress-reporting.d.ts +11 -0
- package/dist/lib/progress-reporting.js +13 -0
- package/dist/prompts.js +3 -3
- package/dist/resources/generated-instructions.js +14 -14
- package/dist/resources/tool-catalog.js +9 -9
- package/dist/resources/tool-info.js +5 -5
- package/dist/resources/workflows.js +17 -17
- package/dist/schemas.js +21 -90
- package/dist/server/bootstrap.js +2 -2
- package/dist/tools/apply-patch.js +3 -0
- package/dist/tools/calculate-hash.js +12 -12
- package/dist/tools/delete-file.js +5 -2
- package/dist/tools/list-directory.js +3 -21
- package/dist/tools/read-multiple.js +11 -21
- package/dist/tools/replace-in-files.js +10 -11
- package/dist/tools/search-content.js +2 -2
- package/dist/tools/search-files.js +3 -21
- package/dist/tools/shared.d.ts +19 -0
- package/dist/tools/shared.js +64 -18
- package/dist/tools/stat-many.js +11 -22
- package/package.json +6 -2
|
@@ -3,22 +3,19 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
4
|
import { Worker } from 'node:worker_threads';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import RE2 from 're2';
|
|
7
|
-
import safeRegex from 'safe-regex2';
|
|
8
6
|
import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_SEARCH_MAX_FILES, DEFAULT_SEARCH_TIMEOUT_MS, MAX_LINE_CONTENT_LENGTH, MAX_SEARCHABLE_FILE_SIZE, SEARCH_WORKERS, } from '../constants.js';
|
|
9
7
|
import { ErrorCode, formatUnknownErrorMessage, isTimeoutLikeError, McpError, } from '../errors.js';
|
|
10
|
-
import { assertNotAborted,
|
|
8
|
+
import { assertNotAborted, isProbablyBinary, withAbort, withTimedAbortSignal, } from '../fs-helpers.js';
|
|
9
|
+
import { mergeOptions, omitOptionKeys } from '../option-utils.js';
|
|
11
10
|
import { assertAllowedFileAccess, isSensitivePath } from '../path-policy.js';
|
|
12
11
|
import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
|
|
12
|
+
import { reportPeriodicProgress } from '../progress-reporting.js';
|
|
13
13
|
import { withOptionalStoppedReason } from './common.js';
|
|
14
14
|
import { globEntries } from './glob-engine.js';
|
|
15
|
+
import { buildGlobOptions } from './glob-helpers.js';
|
|
16
|
+
import { buildMatcher, validatePattern } from './search-matcher.js';
|
|
15
17
|
// --- Configuration & Schemas ---
|
|
16
18
|
const INTERNAL_MAX_RESULTS = 500;
|
|
17
|
-
export const MatcherOptionsSchema = z.strictObject({
|
|
18
|
-
caseSensitive: z.boolean(),
|
|
19
|
-
wholeWord: z.boolean(),
|
|
20
|
-
isLiteral: z.boolean(),
|
|
21
|
-
});
|
|
22
19
|
const SearchOptionsSchema = z.strictObject({
|
|
23
20
|
filePattern: z.string().min(1),
|
|
24
21
|
excludePatterns: z.array(z.string()),
|
|
@@ -55,80 +52,14 @@ const ERROR_SCAN_CANCELLED = 'Scan cancelled';
|
|
|
55
52
|
const ERROR_WORKER_POOL_CLOSED = 'Worker pool closed';
|
|
56
53
|
// --- Helpers ---
|
|
57
54
|
function resolveOptions(options) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
delete rest.onProgress;
|
|
61
|
-
const merged = { ...DEFAULTS, ...rest };
|
|
55
|
+
const normalizedOptions = omitOptionKeys(options, ['signal', 'onProgress']);
|
|
56
|
+
const merged = mergeOptions(DEFAULTS, normalizedOptions);
|
|
62
57
|
const result = SearchOptionsSchema.safeParse(merged);
|
|
63
58
|
if (!result.success) {
|
|
64
59
|
throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid search options: ${result.error.message}`, undefined, { errors: z.treeifyError(result.error) });
|
|
65
60
|
}
|
|
66
61
|
return result.data;
|
|
67
62
|
}
|
|
68
|
-
function countRegexLineMatches(regex, line) {
|
|
69
|
-
regex.lastIndex = 0;
|
|
70
|
-
let count = 0;
|
|
71
|
-
while (regex.exec(line) !== null) {
|
|
72
|
-
count++;
|
|
73
|
-
if (regex.lastIndex === 0)
|
|
74
|
-
regex.lastIndex++;
|
|
75
|
-
}
|
|
76
|
-
return count;
|
|
77
|
-
}
|
|
78
|
-
function escapeLiteral(pattern) {
|
|
79
|
-
return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
80
|
-
}
|
|
81
|
-
function buildRegexPattern(pattern, options) {
|
|
82
|
-
const escaped = options.isLiteral ? escapeLiteral(pattern) : pattern;
|
|
83
|
-
return options.wholeWord ? `\\b${escaped}\\b` : escaped;
|
|
84
|
-
}
|
|
85
|
-
function validatePattern(pattern, options) {
|
|
86
|
-
if (options.isLiteral && pattern.length === 0)
|
|
87
|
-
return;
|
|
88
|
-
if (options.isLiteral && !options.wholeWord)
|
|
89
|
-
return;
|
|
90
|
-
const final = buildRegexPattern(pattern, options);
|
|
91
|
-
if (!safeRegex(final)) {
|
|
92
|
-
throw new Error(`Potentially unsafe regular expression (ReDoS risk): ${pattern}`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
function buildLiteralMatcher(pattern, options) {
|
|
96
|
-
if (!options.caseSensitive) {
|
|
97
|
-
const final = escapeLiteral(pattern);
|
|
98
|
-
const regex = new RegExp(final, 'gi');
|
|
99
|
-
return (line) => countRegexLineMatches(regex, line);
|
|
100
|
-
}
|
|
101
|
-
// Fast path for case-sensitive literal
|
|
102
|
-
const needle = pattern;
|
|
103
|
-
if (needle.length === 0)
|
|
104
|
-
return () => 0;
|
|
105
|
-
return (line) => {
|
|
106
|
-
if (line.length === 0)
|
|
107
|
-
return 0;
|
|
108
|
-
let count = 0;
|
|
109
|
-
let pos = line.indexOf(needle);
|
|
110
|
-
while (pos !== -1) {
|
|
111
|
-
count++;
|
|
112
|
-
pos = line.indexOf(needle, pos + needle.length);
|
|
113
|
-
}
|
|
114
|
-
return count;
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
function buildRegexMatcher(final, caseSensitive) {
|
|
118
|
-
const regex = new RE2(final, caseSensitive ? 'g' : 'gi');
|
|
119
|
-
return (line) => countRegexLineMatches(regex, line);
|
|
120
|
-
}
|
|
121
|
-
export function buildMatcher(pattern, options) {
|
|
122
|
-
if (options.isLiteral && pattern.length === 0)
|
|
123
|
-
return () => 0;
|
|
124
|
-
if (options.isLiteral && !options.wholeWord) {
|
|
125
|
-
// fast path for simple literal search
|
|
126
|
-
return buildLiteralMatcher(pattern, options);
|
|
127
|
-
}
|
|
128
|
-
const final = buildRegexPattern(pattern, options);
|
|
129
|
-
validatePattern(pattern, options); // Re-validate to be safe
|
|
130
|
-
return buildRegexMatcher(final, options.caseSensitive);
|
|
131
|
-
}
|
|
132
63
|
/**
|
|
133
64
|
* Manages a sliding window of lines and pending context-after buffers.
|
|
134
65
|
*/
|
|
@@ -291,6 +222,25 @@ async function scanFileResolved(resolvedPath, requestedPath, matcher, options, s
|
|
|
291
222
|
await handle.close();
|
|
292
223
|
}
|
|
293
224
|
}
|
|
225
|
+
function buildScanFileOptions(opts) {
|
|
226
|
+
return {
|
|
227
|
+
maxFileSize: opts.maxFileSize,
|
|
228
|
+
skipBinary: opts.skipBinary,
|
|
229
|
+
contextLines: opts.contextLines,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function applyScanOutcome(summary, outcome) {
|
|
233
|
+
if (outcome.matched)
|
|
234
|
+
summary.filesMatched++;
|
|
235
|
+
if (outcome.skippedBinary)
|
|
236
|
+
summary.skippedBinary++;
|
|
237
|
+
if (outcome.skippedTooLarge)
|
|
238
|
+
summary.skippedTooLarge++;
|
|
239
|
+
}
|
|
240
|
+
function markTruncated(summary, reason) {
|
|
241
|
+
summary.truncated = true;
|
|
242
|
+
summary.stoppedReason = reason;
|
|
243
|
+
}
|
|
294
244
|
function createScanSummary() {
|
|
295
245
|
return {
|
|
296
246
|
filesScanned: 0,
|
|
@@ -474,32 +424,21 @@ function getPool() {
|
|
|
474
424
|
async function executeSequential(files, pattern, opts, signal, summary) {
|
|
475
425
|
const matches = [];
|
|
476
426
|
const matcher = buildMatcher(pattern, opts);
|
|
477
|
-
const scanOpts =
|
|
478
|
-
maxFileSize: opts.maxFileSize,
|
|
479
|
-
skipBinary: opts.skipBinary,
|
|
480
|
-
contextLines: opts.contextLines,
|
|
481
|
-
};
|
|
427
|
+
const scanOpts = buildScanFileOptions(opts);
|
|
482
428
|
for await (const file of files) {
|
|
483
429
|
if (signal.aborted) {
|
|
484
|
-
summary
|
|
485
|
-
summary.stoppedReason = 'timeout';
|
|
430
|
+
markTruncated(summary, 'timeout');
|
|
486
431
|
break;
|
|
487
432
|
}
|
|
488
433
|
if (matches.length >= opts.maxResults) {
|
|
489
|
-
summary
|
|
490
|
-
summary.stoppedReason = 'maxResults';
|
|
434
|
+
markTruncated(summary, 'maxResults');
|
|
491
435
|
break;
|
|
492
436
|
}
|
|
493
437
|
try {
|
|
494
438
|
assertAllowedFileAccess(file.requestedPath, file.resolvedPath);
|
|
495
439
|
const remaining = opts.maxResults - matches.length;
|
|
496
440
|
const result = await scanFileResolved(file.resolvedPath, file.requestedPath, matcher, scanOpts, signal, remaining);
|
|
497
|
-
|
|
498
|
-
summary.filesMatched++;
|
|
499
|
-
if (result.skippedBinary)
|
|
500
|
-
summary.skippedBinary++;
|
|
501
|
-
if (result.skippedTooLarge)
|
|
502
|
-
summary.skippedTooLarge++;
|
|
441
|
+
applyScanOutcome(summary, result);
|
|
503
442
|
matches.push(...result.matches);
|
|
504
443
|
}
|
|
505
444
|
catch {
|
|
@@ -542,12 +481,7 @@ function processScanResult(winner, summary, matches, maxResults) {
|
|
|
542
481
|
}
|
|
543
482
|
if (winner.result) {
|
|
544
483
|
const res = winner.result;
|
|
545
|
-
|
|
546
|
-
summary.filesMatched++;
|
|
547
|
-
if (res.skippedBinary)
|
|
548
|
-
summary.skippedBinary++;
|
|
549
|
-
if (res.skippedTooLarge)
|
|
550
|
-
summary.skippedTooLarge++;
|
|
484
|
+
applyScanOutcome(summary, res);
|
|
551
485
|
const remaining = maxResults - matches.length;
|
|
552
486
|
if (remaining > 0 && res.matches.length > 0) {
|
|
553
487
|
const take = Math.min(remaining, res.matches.length);
|
|
@@ -559,13 +493,6 @@ function processScanResult(winner, summary, matches, maxResults) {
|
|
|
559
493
|
}
|
|
560
494
|
}
|
|
561
495
|
}
|
|
562
|
-
function reportSearchProgress(onProgress, current, total, force = false) {
|
|
563
|
-
if (!onProgress || current === 0)
|
|
564
|
-
return;
|
|
565
|
-
if (!force && current % 25 !== 0)
|
|
566
|
-
return;
|
|
567
|
-
onProgress({ current, total });
|
|
568
|
-
}
|
|
569
496
|
async function waitForWinner(pending) {
|
|
570
497
|
const raceCandidates = [];
|
|
571
498
|
for (const task of pending) {
|
|
@@ -582,11 +509,7 @@ async function waitForWinner(pending) {
|
|
|
582
509
|
async function executeParallel(files, pattern, opts, signal, summary) {
|
|
583
510
|
const pool = getPool();
|
|
584
511
|
const matches = [];
|
|
585
|
-
const scanOpts =
|
|
586
|
-
maxFileSize: opts.maxFileSize,
|
|
587
|
-
skipBinary: opts.skipBinary,
|
|
588
|
-
contextLines: opts.contextLines,
|
|
589
|
-
};
|
|
512
|
+
const scanOpts = buildScanFileOptions(opts);
|
|
590
513
|
const matcherOpts = {
|
|
591
514
|
caseSensitive: opts.caseSensitive,
|
|
592
515
|
wholeWord: opts.wholeWord,
|
|
@@ -596,8 +519,7 @@ async function executeParallel(files, pattern, opts, signal, summary) {
|
|
|
596
519
|
const iterator = files[Symbol.asyncIterator]();
|
|
597
520
|
let exhausted = false;
|
|
598
521
|
const onAbort = () => {
|
|
599
|
-
summary
|
|
600
|
-
summary.stoppedReason = 'timeout';
|
|
522
|
+
markTruncated(summary, 'timeout');
|
|
601
523
|
for (const t of pending)
|
|
602
524
|
t.cancel();
|
|
603
525
|
};
|
|
@@ -629,12 +551,10 @@ async function executeParallel(files, pattern, opts, signal, summary) {
|
|
|
629
551
|
}
|
|
630
552
|
// Update summary truncation
|
|
631
553
|
if (signal.aborted) {
|
|
632
|
-
summary
|
|
633
|
-
summary.stoppedReason = 'timeout';
|
|
554
|
+
markTruncated(summary, 'timeout');
|
|
634
555
|
}
|
|
635
556
|
else if (matches.length >= opts.maxResults) {
|
|
636
|
-
summary
|
|
637
|
-
summary.stoppedReason = 'maxResults';
|
|
557
|
+
markTruncated(summary, 'maxResults');
|
|
638
558
|
}
|
|
639
559
|
return matches;
|
|
640
560
|
}
|
|
@@ -649,108 +569,110 @@ export async function scanFileInWorker(resolvedPath, requestedPath, matcher, opt
|
|
|
649
569
|
skippedTooLarge: res.skippedTooLarge,
|
|
650
570
|
};
|
|
651
571
|
}
|
|
572
|
+
async function searchSingleFile(details, opts, pattern, signal) {
|
|
573
|
+
const summary = createScanSummary();
|
|
574
|
+
summary.filesScanned = 1;
|
|
575
|
+
const matcher = buildMatcher(pattern, opts);
|
|
576
|
+
const result = await scanFileResolved(details.resolvedPath, details.requestedPath, matcher, {
|
|
577
|
+
...buildScanFileOptions(opts),
|
|
578
|
+
}, signal, opts.maxResults);
|
|
579
|
+
if (result.matched)
|
|
580
|
+
summary.filesMatched = 1;
|
|
581
|
+
return buildSearchResult(path.dirname(details.resolvedPath), pattern, opts.filePattern, result.matches, summary);
|
|
582
|
+
}
|
|
583
|
+
async function searchDirectory(details, opts, pattern, signal, onProgress) {
|
|
584
|
+
const root = await validateExistingDirectory(details.resolvedPath, signal);
|
|
585
|
+
const rootDirectories = [root];
|
|
586
|
+
const stream = globEntries(buildGlobOptions({
|
|
587
|
+
cwd: root,
|
|
588
|
+
pattern: opts.filePattern,
|
|
589
|
+
excludePatterns: opts.excludePatterns,
|
|
590
|
+
includeHidden: opts.includeHidden,
|
|
591
|
+
baseNameMatch: opts.baseNameMatch,
|
|
592
|
+
caseSensitiveMatch: opts.caseSensitiveFileMatch,
|
|
593
|
+
followSymbolicLinks: false,
|
|
594
|
+
onlyFiles: true,
|
|
595
|
+
stats: false,
|
|
596
|
+
suppressErrors: true,
|
|
597
|
+
}));
|
|
598
|
+
async function* fileGenerator() {
|
|
599
|
+
let scanned = 0;
|
|
600
|
+
for await (const entry of stream) {
|
|
601
|
+
if (signal.aborted)
|
|
602
|
+
break;
|
|
603
|
+
if (scanned >= opts.maxFilesScanned)
|
|
604
|
+
break;
|
|
605
|
+
if (!entry.dirent.isFile())
|
|
606
|
+
continue;
|
|
607
|
+
const normalized = normalizePath(entry.path);
|
|
608
|
+
if (!isPathWithinDirectories(normalized, rootDirectories))
|
|
609
|
+
continue;
|
|
610
|
+
if (isSensitivePath(entry.path, normalized))
|
|
611
|
+
continue;
|
|
612
|
+
scanned++;
|
|
613
|
+
reportPeriodicProgress(onProgress, scanned, {
|
|
614
|
+
total: opts.maxFilesScanned,
|
|
615
|
+
throttleModulo: 25,
|
|
616
|
+
});
|
|
617
|
+
yield { resolvedPath: normalized, requestedPath: entry.path };
|
|
618
|
+
}
|
|
619
|
+
reportPeriodicProgress(onProgress, scanned, {
|
|
620
|
+
total: opts.maxFilesScanned,
|
|
621
|
+
throttleModulo: 25,
|
|
622
|
+
force: true,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
const summary = createScanSummary();
|
|
626
|
+
const resolvedStream = fileGenerator();
|
|
627
|
+
async function* countingStream() {
|
|
628
|
+
for await (const f of resolvedStream) {
|
|
629
|
+
summary.filesScanned++;
|
|
630
|
+
yield f;
|
|
631
|
+
}
|
|
632
|
+
if (summary.filesScanned >= opts.maxFilesScanned) {
|
|
633
|
+
summary.truncated = true;
|
|
634
|
+
summary.stoppedReason = 'maxFiles';
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const matcherOpts = {
|
|
638
|
+
caseSensitive: opts.caseSensitive,
|
|
639
|
+
wholeWord: opts.wholeWord,
|
|
640
|
+
isLiteral: opts.isLiteral,
|
|
641
|
+
};
|
|
642
|
+
validatePattern(pattern, matcherOpts);
|
|
643
|
+
const matches = shouldUseWorkers()
|
|
644
|
+
? await executeParallel(countingStream(), pattern, opts, signal, summary)
|
|
645
|
+
: await executeSequential(countingStream(), pattern, opts, signal, summary);
|
|
646
|
+
return buildSearchResult(root, pattern, opts.filePattern, matches, summary);
|
|
647
|
+
}
|
|
648
|
+
function buildTimeoutSearchResult(basePath, pattern, filePattern) {
|
|
649
|
+
const timeoutSummary = createScanSummary();
|
|
650
|
+
markTruncated(timeoutSummary, 'timeout');
|
|
651
|
+
return buildSearchResult(basePath, pattern, filePattern, [], timeoutSummary);
|
|
652
|
+
}
|
|
652
653
|
export async function searchContent(basePath, pattern, options = {}) {
|
|
653
654
|
if (!basePath.trim())
|
|
654
655
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'basePath required');
|
|
655
656
|
if (typeof pattern !== 'string')
|
|
656
657
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'pattern required');
|
|
657
658
|
const opts = resolveOptions(options);
|
|
658
|
-
const { signal, cleanup } = createTimedAbortSignal(options.signal, opts.timeoutMs);
|
|
659
659
|
try {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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);
|
|
676
|
-
}
|
|
677
|
-
if (!stats.isDirectory()) {
|
|
678
|
-
throw new McpError(ErrorCode.E_INVALID_INPUT, `Path must be file or directory`, basePath);
|
|
679
|
-
}
|
|
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 };
|
|
660
|
+
return await withTimedAbortSignal(options.signal, opts.timeoutMs, async (signal) => {
|
|
661
|
+
const details = await validateExistingPathDetailed(basePath, signal);
|
|
662
|
+
const stats = await withAbort(fsp.stat(details.resolvedPath), signal);
|
|
663
|
+
if (stats.isFile()) {
|
|
664
|
+
return searchSingleFile(details, opts, pattern, signal);
|
|
715
665
|
}
|
|
716
|
-
|
|
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;
|
|
666
|
+
if (!stats.isDirectory()) {
|
|
667
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Path must be file or directory', basePath);
|
|
727
668
|
}
|
|
728
|
-
|
|
729
|
-
|
|
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);
|
|
669
|
+
return searchDirectory(details, opts, pattern, signal, options.onProgress);
|
|
670
|
+
});
|
|
743
671
|
}
|
|
744
672
|
catch (error) {
|
|
745
673
|
if (isTimeoutLikeError(error)) {
|
|
746
|
-
|
|
747
|
-
timeoutSummary.truncated = true;
|
|
748
|
-
timeoutSummary.stoppedReason = 'timeout';
|
|
749
|
-
return buildSearchResult(basePath, pattern, opts.filePattern, [], timeoutSummary);
|
|
674
|
+
return buildTimeoutSearchResult(basePath, pattern, opts.filePattern);
|
|
750
675
|
}
|
|
751
676
|
throw error;
|
|
752
677
|
}
|
|
753
|
-
finally {
|
|
754
|
-
cleanup();
|
|
755
|
-
}
|
|
756
678
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import { DEFAULT_SEARCH_MAX_FILES, DEFAULT_SEARCH_TIMEOUT_MS, } from '../constants.js';
|
|
3
|
-
import {
|
|
3
|
+
import { withTimedAbortSignal } from '../fs-helpers.js';
|
|
4
4
|
import { isSensitivePath } from '../path-policy.js';
|
|
5
5
|
import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
|
|
6
|
-
import {
|
|
6
|
+
import { reportPeriodicProgress } from '../progress-reporting.js';
|
|
7
|
+
import { compareOptionalNumberDesc, compareStringValues, isEntryAccessibleByType, needsStatsForSort, resolveEntryType, resolveStopReason, stableSortByDerivedString, withOptionalStoppedReason, } from './common.js';
|
|
7
8
|
import { isIgnoredByGitignore, loadRootGitignore } from './gitignore.js';
|
|
8
|
-
import { globEntries
|
|
9
|
+
import { globEntries } from './glob-engine.js';
|
|
10
|
+
import { buildGlobOptions } from './glob-helpers.js';
|
|
9
11
|
// Internal default for find tool - not exposed to MCP users
|
|
10
12
|
const INTERNAL_MAX_RESULTS = 1000;
|
|
11
13
|
function normalizeOptions(options) {
|
|
@@ -41,17 +43,17 @@ function buildSearchResult(entry, entryType, needsStats) {
|
|
|
41
43
|
...(modified !== undefined ? { modified } : {}),
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
|
-
function markStopped(state, reason) {
|
|
45
|
-
state.truncated = true;
|
|
46
|
-
state.stoppedReason = reason;
|
|
47
|
-
}
|
|
48
46
|
function shouldStopCollecting(state, normalized, signal) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
const stopReason = resolveStopReason({
|
|
48
|
+
signal,
|
|
49
|
+
current: state.filesScanned,
|
|
50
|
+
max: normalized.maxFilesScanned,
|
|
51
|
+
abortedReason: 'timeout',
|
|
52
|
+
maxReason: 'maxFiles',
|
|
53
|
+
});
|
|
54
|
+
if (stopReason !== undefined) {
|
|
55
|
+
state.truncated = true;
|
|
56
|
+
state.stoppedReason = stopReason;
|
|
55
57
|
return true;
|
|
56
58
|
}
|
|
57
59
|
return false;
|
|
@@ -68,7 +70,7 @@ function createCollectState() {
|
|
|
68
70
|
};
|
|
69
71
|
}
|
|
70
72
|
function buildSearchStream(root, pattern, excludePatterns, normalized, needsStats) {
|
|
71
|
-
const options = {
|
|
73
|
+
const options = buildGlobOptions({
|
|
72
74
|
cwd: root,
|
|
73
75
|
pattern,
|
|
74
76
|
excludePatterns,
|
|
@@ -78,10 +80,10 @@ function buildSearchStream(root, pattern, excludePatterns, normalized, needsStat
|
|
|
78
80
|
followSymbolicLinks: false,
|
|
79
81
|
onlyFiles: true,
|
|
80
82
|
stats: needsStats,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
83
|
+
...(normalized.maxDepth !== undefined
|
|
84
|
+
? { maxDepth: normalized.maxDepth }
|
|
85
|
+
: {}),
|
|
86
|
+
});
|
|
85
87
|
return globEntries(options);
|
|
86
88
|
}
|
|
87
89
|
function buildCollectResult(state) {
|
|
@@ -99,22 +101,19 @@ function buildCollectResult(state) {
|
|
|
99
101
|
function handleEntry(entry, entryType, needsStats, normalized, state) {
|
|
100
102
|
state.results.push(buildSearchResult(entry, entryType, needsStats));
|
|
101
103
|
if (state.results.length >= normalized.maxResults) {
|
|
102
|
-
|
|
104
|
+
state.truncated = true;
|
|
105
|
+
state.stoppedReason = 'maxResults';
|
|
103
106
|
}
|
|
104
107
|
}
|
|
105
|
-
function
|
|
106
|
-
if (!onProgress || current === 0)
|
|
107
|
-
return;
|
|
108
|
-
if (!force && current % 25 !== 0)
|
|
109
|
-
return;
|
|
110
|
-
onProgress({ current, total });
|
|
111
|
-
}
|
|
112
|
-
async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, onProgress) {
|
|
108
|
+
async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress) {
|
|
113
109
|
for await (const entry of stream) {
|
|
114
110
|
if (shouldStopCollecting(state, normalized, signal))
|
|
115
111
|
break;
|
|
116
112
|
state.filesScanned++;
|
|
117
|
-
|
|
113
|
+
reportPeriodicProgress(onProgress, state.filesScanned, {
|
|
114
|
+
total: normalized.maxFilesScanned,
|
|
115
|
+
throttleModulo: 25,
|
|
116
|
+
});
|
|
118
117
|
if (isEntryIgnoredByGitignore(gitignoreMatcher, root, entry.path, entry.relativePath)) {
|
|
119
118
|
continue;
|
|
120
119
|
}
|
|
@@ -122,7 +121,7 @@ async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher
|
|
|
122
121
|
if (!shouldIncludeEntry(entryType, normalized)) {
|
|
123
122
|
continue;
|
|
124
123
|
}
|
|
125
|
-
const isAccessible = await
|
|
124
|
+
const isAccessible = await isEntryAccessibleByType(entry.path, entryType, rootDirectories, signal, accessDeps);
|
|
126
125
|
if (!isAccessible) {
|
|
127
126
|
state.skippedInaccessible++;
|
|
128
127
|
continue;
|
|
@@ -131,38 +130,32 @@ async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher
|
|
|
131
130
|
if (state.truncated)
|
|
132
131
|
break;
|
|
133
132
|
}
|
|
134
|
-
|
|
133
|
+
reportPeriodicProgress(onProgress, state.filesScanned, {
|
|
134
|
+
total: normalized.maxFilesScanned,
|
|
135
|
+
throttleModulo: 25,
|
|
136
|
+
force: true,
|
|
137
|
+
});
|
|
135
138
|
}
|
|
136
139
|
function isEntryIgnoredByGitignore(matcher, root, entryPath, relativePath) {
|
|
137
140
|
if (!matcher)
|
|
138
141
|
return false;
|
|
139
142
|
return isIgnoredByGitignore(matcher, root, entryPath, relativePath ? { relativePath } : {});
|
|
140
143
|
}
|
|
141
|
-
async function isEntryAccessible(entry, entryType, rootDirectories, signal) {
|
|
142
|
-
if (entryType === 'symlink') {
|
|
143
|
-
try {
|
|
144
|
-
const validated = await validateExistingPathDetailed(entry.path, signal);
|
|
145
|
-
return !isSensitivePath(validated.requestedPath, validated.resolvedPath);
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
const resolvedPath = normalizePath(entry.path);
|
|
152
|
-
if (!isPathWithinDirectories(resolvedPath, rootDirectories)) {
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
return !isSensitivePath(entry.path, resolvedPath);
|
|
156
|
-
}
|
|
157
144
|
async function collectSearchResults(root, pattern, excludePatterns, normalized, signal, onProgress) {
|
|
158
145
|
const needsStats = needsStatsForSort(normalized.sortBy);
|
|
159
146
|
const stream = buildSearchStream(root, pattern, excludePatterns, normalized, needsStats);
|
|
160
147
|
const state = createCollectState();
|
|
161
148
|
const rootDirectories = [root];
|
|
149
|
+
const accessDeps = {
|
|
150
|
+
normalizePath,
|
|
151
|
+
isPathWithinDirectories,
|
|
152
|
+
isSensitivePath,
|
|
153
|
+
validateSymlinkPath: validateExistingPathDetailed,
|
|
154
|
+
};
|
|
162
155
|
const gitignoreMatcher = normalized.respectGitignore
|
|
163
156
|
? await loadRootGitignore(root, signal)
|
|
164
157
|
: null;
|
|
165
|
-
await collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, onProgress);
|
|
158
|
+
await collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress);
|
|
166
159
|
return buildCollectResult(state);
|
|
167
160
|
}
|
|
168
161
|
function buildSearchSummary(results, filesScanned, truncated, stoppedReason, skippedInaccessible) {
|
|
@@ -174,27 +167,17 @@ function buildSearchSummary(results, filesScanned, truncated, stoppedReason, ski
|
|
|
174
167
|
};
|
|
175
168
|
return withOptionalStoppedReason(summary, stoppedReason);
|
|
176
169
|
}
|
|
177
|
-
const collator = new Intl.Collator(undefined, { numeric: true });
|
|
178
|
-
function compareString(a, b) {
|
|
179
|
-
return collator.compare(a ?? '', b ?? '');
|
|
180
|
-
}
|
|
181
170
|
function compareNameThenPath(a, b) {
|
|
182
|
-
const nameCompare =
|
|
171
|
+
const nameCompare = compareStringValues(a.name, b.name);
|
|
183
172
|
if (nameCompare !== 0)
|
|
184
173
|
return nameCompare;
|
|
185
|
-
return
|
|
174
|
+
return compareStringValues(a.path, b.path);
|
|
186
175
|
}
|
|
187
176
|
function comparePathThenName(a, b) {
|
|
188
|
-
const pathCompare =
|
|
177
|
+
const pathCompare = compareStringValues(a.path, b.path);
|
|
189
178
|
if (pathCompare !== 0)
|
|
190
179
|
return pathCompare;
|
|
191
|
-
return
|
|
192
|
-
}
|
|
193
|
-
function compareOptionalNumberDesc(left, right, tieBreak) {
|
|
194
|
-
const diff = (right ?? 0) - (left ?? 0);
|
|
195
|
-
if (diff !== 0)
|
|
196
|
-
return diff;
|
|
197
|
-
return tieBreak();
|
|
180
|
+
return compareStringValues(a.name, b.name);
|
|
198
181
|
}
|
|
199
182
|
const SORT_COMPARATORS = {
|
|
200
183
|
size: (a, b) => compareOptionalNumberDesc(a.size, b.size, () => compareNameThenPath(a, b)),
|
|
@@ -204,32 +187,7 @@ const SORT_COMPARATORS = {
|
|
|
204
187
|
};
|
|
205
188
|
export function sortSearchResults(results, sortBy) {
|
|
206
189
|
if (sortBy === 'name') {
|
|
207
|
-
|
|
208
|
-
for (let index = 0; index < results.length; index += 1) {
|
|
209
|
-
const item = results[index];
|
|
210
|
-
if (!item)
|
|
211
|
-
continue;
|
|
212
|
-
decorated.push({
|
|
213
|
-
item,
|
|
214
|
-
baseName: path.basename(item.path ?? ''),
|
|
215
|
-
index,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
decorated.sort((a, b) => {
|
|
219
|
-
const baseCompare = compareString(a.baseName, b.baseName);
|
|
220
|
-
if (baseCompare !== 0)
|
|
221
|
-
return baseCompare;
|
|
222
|
-
const pathCompare = compareString(a.item.path, b.item.path);
|
|
223
|
-
if (pathCompare !== 0)
|
|
224
|
-
return pathCompare;
|
|
225
|
-
return a.index - b.index;
|
|
226
|
-
});
|
|
227
|
-
for (let index = 0; index < decorated.length; index += 1) {
|
|
228
|
-
const entry = decorated[index];
|
|
229
|
-
if (entry) {
|
|
230
|
-
results[index] = entry.item;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
190
|
+
stableSortByDerivedString(results, (item) => path.basename(item.path ?? ''), (left, right) => comparePathThenName(left, right));
|
|
233
191
|
return;
|
|
234
192
|
}
|
|
235
193
|
const comparator = SORT_COMPARATORS[sortBy];
|
|
@@ -245,9 +203,8 @@ async function runSearchFiles(root, pattern, excludePatterns, normalized, signal
|
|
|
245
203
|
}
|
|
246
204
|
export async function searchFiles(basePath, pattern, excludePatterns = [], options = {}) {
|
|
247
205
|
const normalized = normalizeOptions(options);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
try {
|
|
206
|
+
return withTimedAbortSignal(options.signal, normalized.timeoutMs, async (signal) => {
|
|
207
|
+
const root = await validateExistingDirectory(basePath, signal);
|
|
251
208
|
const { results, summary } = await runSearchFiles(root, pattern, excludePatterns, normalized, signal, options.onProgress);
|
|
252
209
|
return {
|
|
253
210
|
basePath: root,
|
|
@@ -255,8 +212,5 @@ export async function searchFiles(basePath, pattern, excludePatterns = [], optio
|
|
|
255
212
|
results,
|
|
256
213
|
summary,
|
|
257
214
|
};
|
|
258
|
-
}
|
|
259
|
-
finally {
|
|
260
|
-
cleanup();
|
|
261
|
-
}
|
|
215
|
+
});
|
|
262
216
|
}
|