@j0hanz/filesystem-mcp 1.14.0 → 1.15.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -71
  3. package/dist/cli.js +2 -2
  4. package/dist/completions.js +54 -51
  5. package/dist/config.d.ts +13 -13
  6. package/dist/config.js +12 -12
  7. package/dist/index.js +1 -1
  8. package/dist/lib/abort.d.ts +7 -0
  9. package/dist/lib/abort.js +81 -0
  10. package/dist/lib/constants.d.ts +3 -1
  11. package/dist/lib/constants.js +8 -2
  12. package/dist/lib/errors.d.ts +7 -3
  13. package/dist/lib/errors.js +59 -39
  14. package/dist/lib/file-operations/core.d.ts +3 -3
  15. package/dist/lib/file-operations/core.js +23 -20
  16. package/dist/lib/file-operations/metadata.d.ts +2 -2
  17. package/dist/lib/file-operations/metadata.js +60 -19
  18. package/dist/lib/file-operations/search.js +83 -84
  19. package/dist/lib/file-operations/traversal.js +13 -15
  20. package/dist/lib/fs-helpers.d.ts +3 -10
  21. package/dist/lib/fs-helpers.js +20 -98
  22. package/dist/lib/globs.js +1 -1
  23. package/dist/lib/logger.d.ts +28 -0
  24. package/dist/lib/logger.js +91 -0
  25. package/dist/lib/observability.d.ts +7 -0
  26. package/dist/lib/observability.js +19 -9
  27. package/dist/lib/paths.js +55 -55
  28. package/dist/lib/resource-store.js +4 -4
  29. package/dist/lib/utils.d.ts +0 -12
  30. package/dist/lib/utils.js +0 -13
  31. package/dist/resources/generated-instructions.js +40 -31
  32. package/dist/resources/tool-catalog.js +32 -26
  33. package/dist/resources/tool-info.js +34 -29
  34. package/dist/resources/workflows.js +39 -18
  35. package/dist/resources.d.ts +1 -1
  36. package/dist/resources.js +4 -4
  37. package/dist/schemas.d.ts +66 -66
  38. package/dist/schemas.js +21 -44
  39. package/dist/server/bootstrap.d.ts +12 -11
  40. package/dist/server/bootstrap.js +95 -86
  41. package/dist/server/roots-manager.d.ts +5 -2
  42. package/dist/server/roots-manager.js +8 -6
  43. package/dist/server/task-store.d.ts +10 -0
  44. package/dist/server/task-store.js +73 -0
  45. package/dist/tools/apply-patch.js +26 -18
  46. package/dist/tools/calculate-hash.js +11 -22
  47. package/dist/tools/create-directory.js +10 -8
  48. package/dist/tools/delete-file.js +17 -15
  49. package/dist/tools/diff-files.js +15 -15
  50. package/dist/tools/edit-file.js +7 -4
  51. package/dist/tools/list-directory.js +6 -6
  52. package/dist/tools/move-file.js +98 -81
  53. package/dist/tools/read-multiple.js +4 -4
  54. package/dist/tools/read.js +5 -5
  55. package/dist/tools/replace-in-files.js +20 -23
  56. package/dist/tools/roots.js +1 -1
  57. package/dist/tools/search-content.js +32 -41
  58. package/dist/tools/search-files.js +54 -41
  59. package/dist/tools/shared.d.ts +3 -0
  60. package/dist/tools/shared.js +70 -28
  61. package/dist/tools/stat-many.js +11 -7
  62. package/dist/tools/stat.js +4 -4
  63. package/dist/tools/task-support.d.ts +10 -9
  64. package/dist/tools/task-support.js +94 -23
  65. package/dist/tools/tree.js +3 -3
  66. package/dist/tools/write-file.js +10 -7
  67. package/package.json +9 -9
@@ -1,12 +1,53 @@
1
- import * as fsp from 'node:fs/promises';
2
- import * as path from 'node:path';
1
+ import { lstat, readdir, readlink, stat, } from 'node:fs';
2
+ import { basename, join, parse, relative } from 'node:path';
3
+ import { assertNotAborted, withAbort, withTimedAbortSignal } from '../abort.js';
3
4
  import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_LIST_MAX_ENTRIES, DEFAULT_MAX_DEPTH, DEFAULT_READ_MANY_MAX_TOTAL_SIZE, DEFAULT_SEARCH_TIMEOUT_MS, getMimeType, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from '../constants.js';
4
5
  import { isAbortError } from '../errors.js';
5
- import { assertNotAborted, getFileType, isHidden, processInParallel, readFile, readFileWithStats, withAbort, withTimedAbortSignal, } from '../fs-helpers.js';
6
+ import { getFileType, isHidden, processInParallel, readFile, readFileWithStats, } from '../fs-helpers.js';
6
7
  import { assertSafeGlobPattern } from '../globs.js';
7
8
  import { assertAllowedFileAccess, isPathWithinDirectories, isSensitivePath, normalizePath, toPosixPath, validateExistingDirectory, validateExistingPath, validateExistingPathDetailed, } from '../paths.js';
8
9
  import { applyIndexedErrors, applyIndexedValues, isEntryAccessibleByType, isIgnoredByGitignore, loadRootGitignore, needsStatsForSort, resolveEntryType, resolveStopReason, withOptionalStoppedReason, } from './core.js';
9
10
  import { globEntries } from './traversal.js';
11
+ function statAsync(filePath) {
12
+ return new Promise((resolve, reject) => {
13
+ stat(filePath, (err, stats) => {
14
+ if (err)
15
+ reject(err);
16
+ else
17
+ resolve(stats);
18
+ });
19
+ });
20
+ }
21
+ function readlinkAsync(filePath) {
22
+ return new Promise((resolve, reject) => {
23
+ readlink(filePath, (err, linkString) => {
24
+ if (err)
25
+ reject(err);
26
+ else
27
+ resolve(linkString);
28
+ });
29
+ });
30
+ }
31
+ function readdirAsync(dirPath, options) {
32
+ return new Promise((resolve, reject) => {
33
+ readdir(dirPath, options, (err, files) => {
34
+ if (err)
35
+ reject(err);
36
+ else
37
+ resolve(files);
38
+ });
39
+ });
40
+ }
41
+ function lstatAsync(filePath) {
42
+ return new Promise((resolve, reject) => {
43
+ lstat(filePath, (err, stats) => {
44
+ if (err)
45
+ reject(err);
46
+ else
47
+ resolve(stats);
48
+ });
49
+ });
50
+ }
10
51
  const ACCESS_DEPS = {
11
52
  normalizePath,
12
53
  isPathWithinDirectories,
@@ -49,7 +90,7 @@ function buildFileInfoResult(name, requestedPath, isSymlink, stats, mimeType, sy
49
90
  async function getSymlinkTarget(pathToRead, signal) {
50
91
  assertNotAborted(signal);
51
92
  try {
52
- return await withAbort(fsp.readlink(pathToRead), signal);
93
+ return await withAbort(readlinkAsync(pathToRead), signal);
53
94
  }
54
95
  catch (error) {
55
96
  if (isAbortError(error))
@@ -62,14 +103,14 @@ export async function getFileInfo(filePath, options = {}) {
62
103
  assertNotAborted(signal);
63
104
  const { requestedPath, resolvedPath, isSymlink } = await validateExistingPathDetailed(filePath, signal);
64
105
  assertAllowedFileAccess(requestedPath, resolvedPath);
65
- const { base: name, ext: rawExt } = path.parse(requestedPath);
106
+ const { base: name, ext: rawExt } = parse(requestedPath);
66
107
  const ext = rawExt.toLowerCase();
67
108
  const includeMimeType = options.includeMimeType !== false;
68
109
  const mimeType = includeMimeType && ext.length > 0 ? getMimeType(ext) : undefined;
69
110
  const symlinkTarget = isSymlink
70
111
  ? await getSymlinkTarget(requestedPath, signal)
71
112
  : undefined;
72
- const stats = await withAbort(fsp.stat(resolvedPath), signal);
113
+ const stats = await withAbort(statAsync(resolvedPath), signal);
73
114
  return buildFileInfoResult(name, requestedPath, isSymlink, stats, mimeType, symlinkTarget);
74
115
  }
75
116
  function buildEmptyResult() {
@@ -131,7 +172,7 @@ export async function getMultipleFileInfo(paths, options = {}) {
131
172
  : undefined,
132
173
  buildValue: (resolvedIndex, error) => ({
133
174
  path: paths[resolvedIndex] ?? UNKNOWN_PATH,
134
- error: error.message,
175
+ error,
135
176
  }),
136
177
  });
137
178
  return {
@@ -168,12 +209,12 @@ function resolveMaxDepth(normalized) {
168
209
  return normalized.pattern ? normalized.maxDepth : 1;
169
210
  }
170
211
  async function* readDirectoryEntries(basePath, normalized, needsStats, signal) {
171
- const dirents = await withAbort(fsp.readdir(basePath, { withFileTypes: true }), signal);
212
+ const dirents = await withAbort(readdirAsync(basePath, { withFileTypes: true }), signal);
172
213
  if (!needsStats) {
173
214
  for (const dirent of dirents) {
174
215
  if (!normalized.includeHidden && isHidden(dirent.name))
175
216
  continue;
176
- yield { path: path.join(basePath, dirent.name), dirent };
217
+ yield { path: join(basePath, dirent.name), dirent };
177
218
  }
178
219
  return;
179
220
  }
@@ -181,12 +222,12 @@ async function* readDirectoryEntries(basePath, normalized, needsStats, signal) {
181
222
  for (const dirent of dirents) {
182
223
  if (!normalized.includeHidden && isHidden(dirent.name))
183
224
  continue;
184
- filtered.push({ dirent, entryPath: path.join(basePath, dirent.name) });
225
+ filtered.push({ dirent, entryPath: join(basePath, dirent.name) });
185
226
  }
186
227
  const { results, errors } = await processInParallel(filtered, async ({ entryPath, dirent }) => ({
187
228
  path: entryPath,
188
229
  dirent,
189
- stats: await withAbort(fsp.lstat(entryPath), signal),
230
+ stats: await withAbort(lstatAsync(entryPath), signal),
190
231
  }), PARALLEL_CONCURRENCY, signal);
191
232
  if (errors.length > 0) {
192
233
  throw errors[0]?.error ?? new Error('Failed to read entry stats');
@@ -216,13 +257,13 @@ function shouldUseFastPath(normalized, maxDepth) {
216
257
  maxDepth === 1);
217
258
  }
218
259
  function resolveRelativePath(basePath, entryPath) {
219
- return path.relative(basePath, entryPath) || path.basename(entryPath);
260
+ return relative(basePath, entryPath) || basename(entryPath);
220
261
  }
221
262
  async function resolveSymlinkTarget(entryType, includeSymlinkTargets, entryPath) {
222
263
  if (entryType !== 'symlink' || !includeSymlinkTargets) {
223
264
  return undefined;
224
265
  }
225
- return fsp.readlink(entryPath).catch(() => undefined);
266
+ return readlinkAsync(entryPath).catch(() => undefined);
226
267
  }
227
268
  function updateTotals(entryType, totals) {
228
269
  if (entryType === 'file')
@@ -234,7 +275,7 @@ function buildDirectoryEntry(basePath, entry, entryType, needsStats, symlinkTarg
234
275
  const size = needsStats && entry.stats?.isFile() ? entry.stats.size : undefined;
235
276
  const modified = needsStats ? entry.stats?.mtime : undefined;
236
277
  return {
237
- name: path.basename(entry.path),
278
+ name: basename(entry.path),
238
279
  path: entry.path,
239
280
  relativePath: resolveRelativePath(basePath, entry.path),
240
281
  type: entryType,
@@ -423,7 +464,7 @@ async function resolveTreeEntry(entry, root, rootDirectories, gitignoreMatcher,
423
464
  return null;
424
465
  }
425
466
  const relativePosix = toPosixPath(resolveRelativePath(root, entry.path));
426
- const name = path.basename(entry.path);
467
+ const name = basename(entry.path);
427
468
  return { type, relativePosix, name };
428
469
  }
429
470
  function upsertChildNode(parent, nodeByPath, resolved, childPathIndexByParent) {
@@ -523,7 +564,7 @@ export async function treeDirectory(dirPath, options = {}) {
523
564
  ? null
524
565
  : await loadRootGitignore(root, signal);
525
566
  const rootNode = {
526
- name: path.basename(root) || root,
567
+ name: basename(root) || root,
527
568
  type: 'directory',
528
569
  relativePath: '.',
529
570
  children: [],
@@ -675,7 +716,7 @@ function resolveNormalizedReadOptions(options) {
675
716
  }
676
717
  async function validateFile(filePath, index, signal) {
677
718
  const validPath = await validateExistingPath(filePath, signal);
678
- const stats = await withAbort(fsp.stat(validPath), signal);
719
+ const stats = await withAbort(statAsync(validPath), signal);
679
720
  return { filePath, index, validPath, stats };
680
721
  }
681
722
  function markRemainingSkipped(startIndex, total, skippedBudget) {
@@ -810,7 +851,7 @@ function applySkippedBudget(output, skippedBudget, filePaths, maxTotalSize) {
810
851
  continue;
811
852
  output[index] = {
812
853
  path: filePath,
813
- error: `Skipped: combined estimated read would exceed maxTotalSize (${maxTotalSize} bytes)`,
854
+ error: new Error(`Skipped: combined estimated read would exceed maxTotalSize (${maxTotalSize} bytes)`),
814
855
  };
815
856
  }
816
857
  }
@@ -829,7 +870,7 @@ export async function readMultipleFiles(filePaths, options = {}) {
829
870
  resolveIndex: (failureIndex) => resolveErrorOriginalIndex(failureIndex, filesToProcess, filePaths.length),
830
871
  buildValue: (resolvedIndex, error) => ({
831
872
  path: filePaths[resolvedIndex] ?? UNKNOWN_PATH,
832
- error: error.message,
873
+ error,
833
874
  }),
834
875
  });
835
876
  applySkippedBudget(output, skippedBudget, filePaths, normalized.maxTotalSize);
@@ -50,22 +50,21 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
50
50
  var e = new Error(message);
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
- import * as fsp from 'node:fs/promises';
54
- import * as path from 'node:path';
55
53
  import { AsyncResource } from 'node:async_hooks';
56
- import { existsSync } from 'node:fs';
57
- import { fileURLToPath, pathToFileURL } from 'node:url';
54
+ import { open, stat } from 'node:fs/promises';
55
+ import { basename, dirname } from 'node:path';
58
56
  import { debuglog } from 'node:util';
59
57
  import { parentPort, threadId, Worker, workerData } from 'node:worker_threads';
60
58
  import RE2 from 're2';
61
- import safeRegex from 'safe-regex2';
62
59
  import { z } from 'zod';
60
+ import { assertNotAborted, withAbort, withTimedAbortSignal } from '../abort.js';
63
61
  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';
64
62
  import { ErrorCode, formatUnknownErrorMessage, isTimeoutLikeError, McpError, } from '../errors.js';
65
- import { assertNotAborted, isProbablyBinary, withAbort, withTimedAbortSignal, } from '../fs-helpers.js';
63
+ import { isProbablyBinary } from '../fs-helpers.js';
64
+ import { isSafeGlobPattern } from '../globs.js';
66
65
  import { startPerfMeasure } from '../observability.js';
67
66
  import { assertAllowedFileAccess, isPathWithinDirectories, isSensitivePath, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../paths.js';
68
- import { mergeOptions, omitOptionKeys, reportPeriodicProgress, } from '../utils.js';
67
+ import { mergeOptions, omitOptionKeys } from '../utils.js';
69
68
  import { compareOptionalNumberDesc, compareStringValues, isEntryAccessibleByType, isIgnoredByGitignore, loadRootGitignore, needsStatsForSort, resolveEntryType, resolveStopReason, stableSortByDerivedString, withOptionalStoppedReason, } from './core.js';
70
69
  import { buildGlobOptions, globEntries } from './traversal.js';
71
70
  function countRegexLineMatches(regex, line) {
@@ -85,20 +84,10 @@ function buildRegexPattern(pattern, options) {
85
84
  const escaped = options.isLiteral ? escapeLiteral(pattern) : pattern;
86
85
  return options.wholeWord ? `\\b${escaped}\\b` : escaped;
87
86
  }
88
- function validatePattern(pattern, options) {
89
- if (options.isLiteral && pattern.length === 0)
90
- return;
91
- if (options.isLiteral && !options.wholeWord)
92
- return;
93
- const final = buildRegexPattern(pattern, options);
94
- if (!safeRegex(final)) {
95
- throw new Error(`Potentially unsafe regular expression (ReDoS risk): ${pattern}`);
96
- }
97
- }
98
87
  function buildLiteralMatcher(pattern, options) {
99
88
  if (!options.caseSensitive) {
100
89
  const final = escapeLiteral(pattern);
101
- const regex = new RegExp(final, 'gi');
90
+ const regex = new RE2(final, 'gi');
102
91
  return (line) => countRegexLineMatches(regex, line);
103
92
  }
104
93
  // Fast path for case-sensitive literal
@@ -130,13 +119,19 @@ function buildMatcher(pattern, options) {
130
119
  return buildLiteralMatcher(pattern, options);
131
120
  }
132
121
  const final = buildRegexPattern(pattern, options);
133
- validatePattern(pattern, options); // Re-validate to be safe
134
122
  return buildRegexMatcher(final, options.caseSensitive);
135
123
  }
136
124
  // --- Configuration & Schemas ---
137
125
  const SEARCH_CONTENT_MAX_RESULTS = 500;
126
+ const SafeFilePatternSchema = z
127
+ .string()
128
+ .min(1, 'Pattern required')
129
+ .max(1000, 'Max 1000 chars')
130
+ .refine((value) => isSafeGlobPattern(value), {
131
+ error: 'Invalid glob or unsafe path (absolute/.. forbidden)',
132
+ });
138
133
  const SearchOptionsSchema = z.strictObject({
139
- filePattern: z.string().min(1),
134
+ filePattern: SafeFilePatternSchema,
140
135
  excludePatterns: z.array(z.string()),
141
136
  caseSensitive: z.boolean(),
142
137
  maxResults: z.int().min(0),
@@ -169,7 +164,6 @@ const DEFAULTS = {
169
164
  };
170
165
  const ERROR_SCAN_CANCELLED = 'Scan cancelled';
171
166
  const ERROR_WORKER_POOL_CLOSED = 'Worker pool closed';
172
- const SEARCH_PROGRESS_THROTTLE_MODULO = 25;
173
167
  const SEARCH_WORKER_NAME_PREFIX = 'filesystem-search';
174
168
  const SEARCH_WORKER_RESOURCE_TYPE = 'SearchWorkerTask';
175
169
  // --- Helpers ---
@@ -178,7 +172,7 @@ function resolveOptions(options) {
178
172
  const merged = mergeOptions(DEFAULTS, normalizedOptions);
179
173
  const result = SearchOptionsSchema.safeParse(merged);
180
174
  if (!result.success) {
181
- throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid search options:\n${z.prettifyError(result.error)}`, undefined, { errors: z.treeifyError(result.error) });
175
+ throw new McpError(ErrorCode.INVALID_INPUT, `Invalid search options:\n${z.prettifyError(result.error)}`, undefined, { errors: z.treeifyError(result.error) });
182
176
  }
183
177
  return result.data;
184
178
  }
@@ -254,9 +248,31 @@ class ContextBuffer {
254
248
  }
255
249
  function trimContent(line) {
256
250
  return line.length > MAX_LINE_CONTENT_LENGTH
257
- ? line.slice(0, MAX_LINE_CONTENT_LENGTH)
251
+ ? `${line.slice(0, MAX_LINE_CONTENT_LENGTH)}\u2026`
258
252
  : line;
259
253
  }
254
+ function processLineMatch(matches, rawLine, lineNumber, matchCount, requestedPath, ctx) {
255
+ const trimmedLine = trimContent(rawLine);
256
+ if (ctx) {
257
+ matches.push({
258
+ file: requestedPath,
259
+ line: lineNumber,
260
+ content: trimmedLine,
261
+ matchCount,
262
+ contextBefore: ctx.snapshotBefore(),
263
+ contextAfter: ctx.scheduleAfter(),
264
+ });
265
+ }
266
+ else {
267
+ matches.push({
268
+ file: requestedPath,
269
+ line: lineNumber,
270
+ content: trimmedLine,
271
+ matchCount,
272
+ });
273
+ }
274
+ return trimmedLine;
275
+ }
260
276
  async function readMatches(handle, requestedPath, matcher, options, maxMatches, isCancelled, signal) {
261
277
  if (maxMatches <= 0) {
262
278
  return [];
@@ -274,26 +290,12 @@ async function readMatches(handle, requestedPath, matcher, options, maxMatches,
274
290
  if (isCancelled())
275
291
  break;
276
292
  const matchCount = matcher(rawLine);
277
- const trimmedLine = hasContext || matchCount > 0 ? trimContent(rawLine) : '';
293
+ let trimmedLine = '';
278
294
  if (matchCount > 0) {
279
- if (ctx) {
280
- matches.push({
281
- file: requestedPath,
282
- line: lineNumber,
283
- content: trimmedLine,
284
- matchCount,
285
- contextBefore: ctx.snapshotBefore(),
286
- contextAfter: ctx.scheduleAfter(),
287
- });
288
- }
289
- else {
290
- matches.push({
291
- file: requestedPath,
292
- line: lineNumber,
293
- content: trimmedLine,
294
- matchCount,
295
- });
296
- }
295
+ trimmedLine = processLineMatch(matches, rawLine, lineNumber, matchCount, requestedPath, ctx);
296
+ }
297
+ else if (hasContext) {
298
+ trimmedLine = trimContent(rawLine);
297
299
  }
298
300
  if (ctx) {
299
301
  ctx.add(trimmedLine);
@@ -315,7 +317,7 @@ async function scanFileResolved(resolvedPath, requestedPath, matcher, options, s
315
317
  const env_1 = { stack: [], error: void 0, hasError: false };
316
318
  try {
317
319
  assertNotAborted(signal);
318
- const handle = __addDisposableResource(env_1, await withAbort(fsp.open(resolvedPath, 'r'), signal), true);
320
+ const handle = __addDisposableResource(env_1, await withAbort(open(resolvedPath, 'r'), signal), true);
319
321
  const stats = await withAbort(handle.stat(), signal);
320
322
  if (stats.size > options.maxFileSize) {
321
323
  return {
@@ -409,12 +411,9 @@ function buildSearchContentResult(root, pattern, filePattern, matches, summary)
409
411
  summary: withOptionalStoppedReason(baseSummary, summary.stoppedReason),
410
412
  };
411
413
  }
412
- const currentDir = path.dirname(fileURLToPath(import.meta.url));
413
- const isSourceContext = currentDir.endsWith('src\\lib\\file-operations') ||
414
- currentDir.endsWith('src/lib/file-operations');
415
- const WORKER_SCRIPT_PATH = path.join(currentDir, isSourceContext ? 'search-worker.ts' : 'search-worker.js');
416
- const WORKER_SCRIPT_URL = pathToFileURL(WORKER_SCRIPT_PATH);
417
- const hasWorkerScript = existsSync(WORKER_SCRIPT_PATH);
414
+ const isSourceContext = import.meta.url.endsWith('.ts');
415
+ const WORKER_SCRIPT_URL = new URL(import.meta.url);
416
+ const hasWorkerScript = true;
418
417
  class SearchWorkerTaskResource extends AsyncResource {
419
418
  #settled = false;
420
419
  constructor() {
@@ -518,9 +517,21 @@ class SearchWorkerPool {
518
517
  if (this.closed)
519
518
  throw new Error(ERROR_WORKER_POOL_CLOSED);
520
519
  const id = this.nextRequestId++;
521
- const workerIndex = this.workerRoundRobin % this.size;
520
+ let workerIndex = 0;
521
+ const workerPendingCounts = new Array(this.size).fill(0);
522
+ for (const p of this.pending.values()) {
523
+ const idx = p.workerIndex;
524
+ workerPendingCounts[idx] = (workerPendingCounts[idx] ?? 0) + 1;
525
+ }
526
+ let minPending = workerPendingCounts[0] ?? 0;
527
+ for (let i = 1; i < this.size; i++) {
528
+ const pendingCount = workerPendingCounts[i] ?? 0;
529
+ if (pendingCount < minPending) {
530
+ minPending = pendingCount;
531
+ workerIndex = i;
532
+ }
533
+ }
522
534
  const worker = this.getWorker(workerIndex);
523
- this.workerRoundRobin++;
524
535
  const promise = new Promise((resolve, reject) => {
525
536
  const resource = new SearchWorkerTaskResource();
526
537
  const pendingRequest = {
@@ -758,7 +769,7 @@ async function searchSingleFile(details, opts, pattern, signal) {
758
769
  }, signal, opts.maxResults);
759
770
  if (result.matched)
760
771
  summary.filesMatched = 1;
761
- return buildSearchContentResult(path.dirname(details.resolvedPath), pattern, opts.filePattern, result.matches, summary);
772
+ return buildSearchContentResult(dirname(details.resolvedPath), pattern, opts.filePattern, result.matches, summary);
762
773
  }
763
774
  async function searchDirectory(details, opts, pattern, signal, onProgress) {
764
775
  const root = await validateExistingDirectory(details.resolvedPath, signal);
@@ -775,12 +786,12 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
775
786
  stats: false,
776
787
  suppressErrors: true,
777
788
  }));
789
+ const summary = createScanSummary();
778
790
  async function* fileGenerator() {
779
- let scanned = 0;
780
791
  for await (const entry of stream) {
781
792
  if (signal.aborted)
782
793
  break;
783
- if (scanned >= opts.maxFilesScanned)
794
+ if (summary.filesScanned >= opts.maxFilesScanned)
784
795
  break;
785
796
  if (!entry.dirent.isFile())
786
797
  continue;
@@ -789,36 +800,25 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
789
800
  continue;
790
801
  if (isSensitivePath(entry.path, normalized))
791
802
  continue;
792
- scanned++;
793
- reportPeriodicProgress(onProgress, scanned, {
803
+ summary.filesScanned++;
804
+ onProgress?.({
805
+ current: summary.filesScanned,
794
806
  total: opts.maxFilesScanned,
795
- throttleModulo: SEARCH_PROGRESS_THROTTLE_MODULO,
796
807
  });
797
808
  yield { resolvedPath: normalized, requestedPath: entry.path };
798
809
  }
799
- reportPeriodicProgress(onProgress, scanned, {
800
- total: opts.maxFilesScanned,
801
- throttleModulo: SEARCH_PROGRESS_THROTTLE_MODULO,
802
- force: true,
803
- });
804
- }
805
- const summary = createScanSummary();
806
- const resolvedStream = fileGenerator();
807
- async function* countingStream() {
808
- for await (const f of resolvedStream) {
809
- summary.filesScanned++;
810
- yield f;
811
- }
812
810
  if (summary.filesScanned >= opts.maxFilesScanned) {
813
811
  summary.truncated = true;
814
812
  summary.stoppedReason = 'maxFiles';
815
813
  }
814
+ onProgress?.({
815
+ current: summary.filesScanned,
816
+ total: opts.maxFilesScanned,
817
+ });
816
818
  }
817
- const matcherOpts = buildMatcherOptions(opts);
818
- validatePattern(pattern, matcherOpts);
819
819
  const matches = shouldUseWorkers()
820
- ? await executeParallel(countingStream(), pattern, opts, signal, summary)
821
- : await executeSequential(countingStream(), pattern, opts, signal, summary);
820
+ ? await executeParallel(fileGenerator(), pattern, opts, signal, summary)
821
+ : await executeSequential(fileGenerator(), pattern, opts, signal, summary);
822
822
  return buildSearchContentResult(root, pattern, opts.filePattern, matches, summary);
823
823
  }
824
824
  function buildTimeoutSearchResult(basePath, pattern, filePattern) {
@@ -828,19 +828,19 @@ function buildTimeoutSearchResult(basePath, pattern, filePattern) {
828
828
  }
829
829
  export async function searchContent(basePath, pattern, options = {}) {
830
830
  if (!basePath.trim())
831
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'basePath required');
831
+ throw new McpError(ErrorCode.INVALID_INPUT, 'basePath required');
832
832
  if (typeof pattern !== 'string')
833
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'pattern required');
833
+ throw new McpError(ErrorCode.INVALID_INPUT, 'pattern required');
834
834
  const opts = resolveOptions(options);
835
835
  try {
836
836
  return await withTimedAbortSignal(options.signal, opts.timeoutMs, async (signal) => {
837
837
  const details = await validateExistingPathDetailed(basePath, signal);
838
- const stats = await withAbort(fsp.stat(details.resolvedPath), signal);
838
+ const stats = await withAbort(stat(details.resolvedPath), signal);
839
839
  if (stats.isFile()) {
840
840
  return searchSingleFile(details, opts, pattern, signal);
841
841
  }
842
842
  if (!stats.isDirectory()) {
843
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Path must be file or directory', basePath);
843
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Path must be file or directory', basePath);
844
844
  }
845
845
  return searchDirectory(details, opts, pattern, signal, options.onProgress);
846
846
  });
@@ -955,9 +955,9 @@ async function collectFromStream(stream, signal, context) {
955
955
  if (shouldStopCollecting(state, normalized, signal))
956
956
  break;
957
957
  state.filesScanned++;
958
- reportPeriodicProgress(onProgress, state.filesScanned, {
958
+ onProgress?.({
959
+ current: state.filesScanned,
959
960
  total: normalized.maxFilesScanned,
960
- throttleModulo: SEARCH_PROGRESS_THROTTLE_MODULO,
961
961
  });
962
962
  if (isEntryIgnoredByGitignore(gitignoreMatcher, root, entry.path, entry.relativePath)) {
963
963
  continue;
@@ -975,10 +975,9 @@ async function collectFromStream(stream, signal, context) {
975
975
  if (state.truncated)
976
976
  break;
977
977
  }
978
- reportPeriodicProgress(onProgress, state.filesScanned, {
978
+ onProgress?.({
979
+ current: state.filesScanned,
979
980
  total: normalized.maxFilesScanned,
980
- throttleModulo: SEARCH_PROGRESS_THROTTLE_MODULO,
981
- force: true,
982
981
  });
983
982
  }
984
983
  function isEntryIgnoredByGitignore(matcher, root, entryPath, relativePath) {
@@ -1041,7 +1040,7 @@ const SORT_COMPARATORS = {
1041
1040
  };
1042
1041
  function sortSearchResults(results, sortBy) {
1043
1042
  if (sortBy === 'name') {
1044
- stableSortByDerivedString(results, (item) => path.basename(item.path ?? ''), (left, right) => comparePathThenName(left, right));
1043
+ stableSortByDerivedString(results, (item) => basename(item.path ?? ''), (left, right) => comparePathThenName(left, right));
1045
1044
  return;
1046
1045
  }
1047
1046
  const comparator = SORT_COMPARATORS[sortBy];
@@ -1,6 +1,5 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
3
- import { glob as fsGlob } from 'node:fs/promises';
1
+ import { glob as fsGlob, lstat, stat } from 'node:fs/promises';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
4
3
  import { getToolContextSnapshot, publishOpsTraceEnd, publishOpsTraceError, publishOpsTraceStart, shouldPublishOpsTrace, startPerfMeasure, } from '../observability.js';
5
4
  import { toPosixPath } from '../paths.js';
6
5
  import { isRecord } from '../utils.js';
@@ -96,7 +95,7 @@ function assertOptionsShape(options) {
96
95
  }
97
96
  }
98
97
  function normalizeOptions(options) {
99
- const cwd = path.resolve(options.cwd);
98
+ const cwd = resolve(options.cwd);
100
99
  const normalizedPattern = normalizePattern(options.pattern, options.baseNameMatch);
101
100
  const patterns = options.includeHidden
102
101
  ? buildHiddenPatterns(normalizedPattern, options.maxDepth ?? DEFAULT_MAX_HIDDEN_DEPTH)
@@ -136,18 +135,16 @@ function isGlobDirentLike(value) {
136
135
  function resolveDirentBase(cwd, parentPath) {
137
136
  if (!parentPath)
138
137
  return cwd;
139
- return path.isAbsolute(parentPath)
140
- ? parentPath
141
- : path.resolve(cwd, parentPath);
138
+ return isAbsolute(parentPath) ? parentPath : resolve(cwd, parentPath);
142
139
  }
143
140
  function resolveStringMatchPath(cwd, match) {
144
- return path.isAbsolute(match) ? match : path.resolve(cwd, match);
141
+ return isAbsolute(match) ? match : resolve(cwd, match);
145
142
  }
146
143
  function* processDirentMatch(match, cwd, maxDepth, seen, onlyFiles) {
147
144
  const base = resolveDirentBase(cwd, match.parentPath);
148
- const absolutePath = path.resolve(base, match.name);
145
+ const absolutePath = resolve(base, match.name);
149
146
  if (maxDepth !== undefined) {
150
- const rel = path.relative(cwd, absolutePath);
147
+ const rel = relative(cwd, absolutePath);
151
148
  if (getRelativeDepth(rel) > maxDepth)
152
149
  return;
153
150
  }
@@ -170,12 +167,12 @@ async function resolveStringMatch(match, cwd, maxDepth, seen, onlyFiles, followS
170
167
  seen.add(absolutePath);
171
168
  try {
172
169
  const stats = followSymlinks
173
- ? await fs.stat(absolutePath)
174
- : await fs.lstat(absolutePath);
170
+ ? await stat(absolutePath)
171
+ : await lstat(absolutePath);
175
172
  if (onlyFiles && !stats.isFile())
176
173
  return null;
177
174
  const entry = { path: absolutePath, dirent: stats };
178
- if (!path.isAbsolute(match)) {
175
+ if (!isAbsolute(match)) {
179
176
  entry.relativePath = match;
180
177
  }
181
178
  if (returnStats)
@@ -190,12 +187,13 @@ async function resolveStringMatch(match, cwd, maxDepth, seen, onlyFiles, followS
190
187
  }
191
188
  async function* processIterable(iterable, context) {
192
189
  const { cwd, maxDepth, seen, onlyFiles, followSymlinks, returnStats, suppressErrors, } = context;
193
- const buffer = [];
190
+ let buffer = [];
194
191
  const flush = async function* () {
195
192
  if (buffer.length === 0)
196
193
  return;
197
194
  // Process buffer concurrently
198
- const currentBuffer = buffer.splice(0, buffer.length);
195
+ const currentBuffer = buffer;
196
+ buffer = [];
199
197
  const results = await Promise.all(currentBuffer.map((match) => resolveStringMatch(match, cwd, maxDepth, seen, onlyFiles, followSymlinks, returnStats, suppressErrors)));
200
198
  for (const entry of results) {
201
199
  if (entry !== null)
@@ -1,13 +1,7 @@
1
- import * as fsp from 'node:fs/promises';
2
1
  import type { Stats } from 'node:fs';
2
+ import { type FileHandle } from 'node:fs/promises';
3
3
  import type { FileType } from '../config.js';
4
- export declare function assertNotAborted(signal?: AbortSignal, message?: string): void;
5
- export declare function withAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T>;
6
- export declare function createTimedAbortSignal(baseSignal: AbortSignal | undefined, timeoutMs?: number): {
7
- signal: AbortSignal;
8
- cleanup: () => void;
9
- };
10
- export declare function withTimedAbortSignal<T>(baseSignal: AbortSignal | undefined, timeoutMs: number | undefined, run: (signal: AbortSignal) => Promise<T>): Promise<T>;
4
+ export { assertNotAborted, createTimedAbortSignal, withAbort, withTimedAbortSignal, } from './abort.js';
11
5
  interface ParallelResult<R> {
12
6
  results: R[];
13
7
  errors: {
@@ -18,7 +12,7 @@ interface ParallelResult<R> {
18
12
  export declare function processInParallel<T, R>(items: T[], processor: (item: T) => Promise<R>, concurrency?: number, signal?: AbortSignal): Promise<ParallelResult<R>>;
19
13
  export declare function getFileType(stats: Stats): FileType;
20
14
  export declare function isHidden(name: string): boolean;
21
- export declare function isProbablyBinary(filePath: string, existingHandle?: fsp.FileHandle, signal?: AbortSignal): Promise<boolean>;
15
+ export declare function isProbablyBinary(filePath: string, existingHandle?: FileHandle, signal?: AbortSignal): Promise<boolean>;
22
16
  type ReadMode = 'head' | 'full' | 'range' | 'tail';
23
17
  interface ReadFileOptions {
24
18
  encoding?: BufferEncoding;
@@ -49,4 +43,3 @@ export declare function atomicWriteFile(filePath: string, content: string, optio
49
43
  encoding?: BufferEncoding;
50
44
  signal?: AbortSignal | undefined;
51
45
  }): Promise<void>;
52
- export {};