@j0hanz/filesystem-mcp 1.7.3 → 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.
@@ -0,0 +1,18 @@
1
+ import type { globEntries } from './glob-engine.js';
2
+ export interface GlobConfig {
3
+ cwd: string;
4
+ pattern: string;
5
+ excludePatterns?: readonly string[];
6
+ includeHidden?: boolean;
7
+ baseNameMatch?: boolean;
8
+ caseSensitiveMatch?: boolean;
9
+ followSymbolicLinks?: boolean;
10
+ onlyFiles?: boolean;
11
+ stats?: boolean;
12
+ maxDepth?: number;
13
+ suppressErrors?: boolean;
14
+ }
15
+ /**
16
+ * Builds standard options for globEntries to ensure consistency across search tools.
17
+ */
18
+ export declare function buildGlobOptions(config: GlobConfig): Parameters<typeof globEntries>[0];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Builds standard options for globEntries to ensure consistency across search tools.
3
+ */
4
+ export function buildGlobOptions(config) {
5
+ const options = {
6
+ cwd: config.cwd,
7
+ pattern: config.pattern,
8
+ excludePatterns: config.excludePatterns ?? [],
9
+ includeHidden: config.includeHidden ?? false,
10
+ baseNameMatch: config.baseNameMatch ?? false,
11
+ caseSensitiveMatch: config.caseSensitiveMatch ?? true,
12
+ followSymbolicLinks: config.followSymbolicLinks ?? false,
13
+ onlyFiles: config.onlyFiles ?? true,
14
+ stats: config.stats ?? false,
15
+ };
16
+ if (config.suppressErrors) {
17
+ options.suppressErrors = config.suppressErrors;
18
+ }
19
+ if (config.maxDepth !== undefined) {
20
+ options.maxDepth = config.maxDepth;
21
+ }
22
+ return options;
23
+ }
@@ -1,7 +1,7 @@
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, isHidden, processInParallel, withAbort, } from '../fs-helpers.js';
4
+ import { isHidden, processInParallel, withAbort, withTimedAbortSignal, } from '../fs-helpers.js';
5
5
  import { isSensitivePath } from '../path-policy.js';
6
6
  import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
7
7
  import { isEntryAccessibleByType, needsStatsForSort, resolveEntryType, resolveStopReason, withOptionalStoppedReason, } from './common.js';
@@ -244,13 +244,9 @@ async function executeListDirectory(basePath, normalized, signal) {
244
244
  }
245
245
  export async function listDirectory(dirPath, options = {}) {
246
246
  const normalized = normalizeOptions(options);
247
- const { signal, cleanup } = createTimedAbortSignal(options.signal, normalized.timeoutMs);
248
- const basePath = await validateExistingDirectory(dirPath, signal);
249
- try {
247
+ return withTimedAbortSignal(options.signal, normalized.timeoutMs, async (signal) => {
248
+ const basePath = await validateExistingDirectory(dirPath, signal);
250
249
  const { entries, summary } = await executeListDirectory(basePath, normalized, signal);
251
250
  return { path: basePath, entries, summary };
252
- }
253
- finally {
254
- cleanup();
255
- }
251
+ });
256
252
  }
@@ -1,12 +1,7 @@
1
1
  import * as fsp from 'node:fs/promises';
2
2
  import { z } from 'zod';
3
3
  import type { ContentMatch, SearchContentResult } from '../../config.js';
4
- export declare const MatcherOptionsSchema: z.ZodObject<{
5
- caseSensitive: z.ZodBoolean;
6
- wholeWord: z.ZodBoolean;
7
- isLiteral: z.ZodBoolean;
8
- }, z.core.$strict>;
9
- export type MatcherOptions = z.infer<typeof MatcherOptionsSchema>;
4
+ import type { Matcher, MatcherOptions } from './search-matcher.js';
10
5
  export interface ScanFileOptions {
11
6
  maxFileSize: number;
12
7
  skipBinary: boolean;
@@ -36,8 +31,6 @@ export interface SearchContentOptions extends Partial<ResolvedOptions> {
36
31
  current: number;
37
32
  }) => void;
38
33
  }
39
- export type Matcher = (line: string) => number;
40
- export declare function buildMatcher(pattern: string, options: MatcherOptions): Matcher;
41
34
  type BinaryDetector = (resolvedPath: string, handle: fsp.FileHandle, signal?: AbortSignal) => Promise<boolean>;
42
35
  export interface ScanRequest {
43
36
  type: 'scan';
@@ -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, createTimedAbortSignal, isProbablyBinary, withAbort, } from '../fs-helpers.js';
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 rest = { ...options };
59
- delete rest.signal;
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
  */
@@ -562,13 +493,6 @@ function processScanResult(winner, summary, matches, maxResults) {
562
493
  }
563
494
  }
564
495
  }
565
- function reportSearchProgress(onProgress, current, total, force = false) {
566
- if (!onProgress || current === 0)
567
- return;
568
- if (!force && current % 25 !== 0)
569
- return;
570
- onProgress({ current, total });
571
- }
572
496
  async function waitForWinner(pending) {
573
497
  const raceCandidates = [];
574
498
  for (const task of pending) {
@@ -659,7 +583,7 @@ async function searchSingleFile(details, opts, pattern, signal) {
659
583
  async function searchDirectory(details, opts, pattern, signal, onProgress) {
660
584
  const root = await validateExistingDirectory(details.resolvedPath, signal);
661
585
  const rootDirectories = [root];
662
- const stream = globEntries({
586
+ const stream = globEntries(buildGlobOptions({
663
587
  cwd: root,
664
588
  pattern: opts.filePattern,
665
589
  excludePatterns: opts.excludePatterns,
@@ -670,7 +594,7 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
670
594
  onlyFiles: true,
671
595
  stats: false,
672
596
  suppressErrors: true,
673
- });
597
+ }));
674
598
  async function* fileGenerator() {
675
599
  let scanned = 0;
676
600
  for await (const entry of stream) {
@@ -686,10 +610,17 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
686
610
  if (isSensitivePath(entry.path, normalized))
687
611
  continue;
688
612
  scanned++;
689
- reportSearchProgress(onProgress, scanned, opts.maxFilesScanned);
613
+ reportPeriodicProgress(onProgress, scanned, {
614
+ total: opts.maxFilesScanned,
615
+ throttleModulo: 25,
616
+ });
690
617
  yield { resolvedPath: normalized, requestedPath: entry.path };
691
618
  }
692
- reportSearchProgress(onProgress, scanned, opts.maxFilesScanned, true);
619
+ reportPeriodicProgress(onProgress, scanned, {
620
+ total: opts.maxFilesScanned,
621
+ throttleModulo: 25,
622
+ force: true,
623
+ });
693
624
  }
694
625
  const summary = createScanSummary();
695
626
  const resolvedStream = fileGenerator();
@@ -725,17 +656,18 @@ export async function searchContent(basePath, pattern, options = {}) {
725
656
  if (typeof pattern !== 'string')
726
657
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'pattern required');
727
658
  const opts = resolveOptions(options);
728
- const { signal, cleanup } = createTimedAbortSignal(options.signal, opts.timeoutMs);
729
659
  try {
730
- const details = await validateExistingPathDetailed(basePath, signal);
731
- const stats = await withAbort(fsp.stat(details.resolvedPath), signal);
732
- if (stats.isFile()) {
733
- return await searchSingleFile(details, opts, pattern, signal);
734
- }
735
- if (!stats.isDirectory()) {
736
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Path must be file or directory', basePath);
737
- }
738
- return await searchDirectory(details, opts, pattern, signal, options.onProgress);
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);
665
+ }
666
+ if (!stats.isDirectory()) {
667
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Path must be file or directory', basePath);
668
+ }
669
+ return searchDirectory(details, opts, pattern, signal, options.onProgress);
670
+ });
739
671
  }
740
672
  catch (error) {
741
673
  if (isTimeoutLikeError(error)) {
@@ -743,7 +675,4 @@ export async function searchContent(basePath, pattern, options = {}) {
743
675
  }
744
676
  throw error;
745
677
  }
746
- finally {
747
- cleanup();
748
- }
749
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 { createTimedAbortSignal } from '../fs-helpers.js';
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 { reportPeriodicProgress } from '../progress-reporting.js';
6
7
  import { compareOptionalNumberDesc, compareStringValues, isEntryAccessibleByType, needsStatsForSort, resolveEntryType, resolveStopReason, stableSortByDerivedString, withOptionalStoppedReason, } from './common.js';
7
8
  import { isIgnoredByGitignore, loadRootGitignore } from './gitignore.js';
8
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) {
@@ -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
- if (normalized.maxDepth !== undefined) {
83
- options.maxDepth = normalized.maxDepth;
84
- }
83
+ ...(normalized.maxDepth !== undefined
84
+ ? { maxDepth: normalized.maxDepth }
85
+ : {}),
86
+ });
85
87
  return globEntries(options);
86
88
  }
87
89
  function buildCollectResult(state) {
@@ -103,19 +105,15 @@ function handleEntry(entry, entryType, needsStats, normalized, state) {
103
105
  state.stoppedReason = 'maxResults';
104
106
  }
105
107
  }
106
- function reportSearchFilesProgress(onProgress, current, total, force = false) {
107
- if (!onProgress || current === 0)
108
- return;
109
- if (!force && current % 25 !== 0)
110
- return;
111
- onProgress({ current, total });
112
- }
113
108
  async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress) {
114
109
  for await (const entry of stream) {
115
110
  if (shouldStopCollecting(state, normalized, signal))
116
111
  break;
117
112
  state.filesScanned++;
118
- reportSearchFilesProgress(onProgress, state.filesScanned, normalized.maxFilesScanned);
113
+ reportPeriodicProgress(onProgress, state.filesScanned, {
114
+ total: normalized.maxFilesScanned,
115
+ throttleModulo: 25,
116
+ });
119
117
  if (isEntryIgnoredByGitignore(gitignoreMatcher, root, entry.path, entry.relativePath)) {
120
118
  continue;
121
119
  }
@@ -132,7 +130,11 @@ async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher
132
130
  if (state.truncated)
133
131
  break;
134
132
  }
135
- reportSearchFilesProgress(onProgress, state.filesScanned, normalized.maxFilesScanned, true);
133
+ reportPeriodicProgress(onProgress, state.filesScanned, {
134
+ total: normalized.maxFilesScanned,
135
+ throttleModulo: 25,
136
+ force: true,
137
+ });
136
138
  }
137
139
  function isEntryIgnoredByGitignore(matcher, root, entryPath, relativePath) {
138
140
  if (!matcher)
@@ -201,9 +203,8 @@ async function runSearchFiles(root, pattern, excludePatterns, normalized, signal
201
203
  }
202
204
  export async function searchFiles(basePath, pattern, excludePatterns = [], options = {}) {
203
205
  const normalized = normalizeOptions(options);
204
- const { signal, cleanup } = createTimedAbortSignal(options.signal, normalized.timeoutMs);
205
- const root = await validateExistingDirectory(basePath, signal);
206
- try {
206
+ return withTimedAbortSignal(options.signal, normalized.timeoutMs, async (signal) => {
207
+ const root = await validateExistingDirectory(basePath, signal);
207
208
  const { results, summary } = await runSearchFiles(root, pattern, excludePatterns, normalized, signal, options.onProgress);
208
209
  return {
209
210
  basePath: root,
@@ -211,8 +212,5 @@ export async function searchFiles(basePath, pattern, excludePatterns = [], optio
211
212
  results,
212
213
  summary,
213
214
  };
214
- }
215
- finally {
216
- cleanup();
217
- }
215
+ });
218
216
  }
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const MatcherOptionsSchema: z.ZodObject<{
3
+ caseSensitive: z.ZodBoolean;
4
+ wholeWord: z.ZodBoolean;
5
+ isLiteral: z.ZodBoolean;
6
+ }, z.core.$strict>;
7
+ export type MatcherOptions = z.infer<typeof MatcherOptionsSchema>;
8
+ export type Matcher = (line: string) => number;
9
+ export declare function validatePattern(pattern: string, options: MatcherOptions): void;
10
+ export declare function buildMatcher(pattern: string, options: MatcherOptions): Matcher;
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import RE2 from 're2';
3
+ import safeRegex from 'safe-regex2';
4
+ export const MatcherOptionsSchema = z.strictObject({
5
+ caseSensitive: z.boolean(),
6
+ wholeWord: z.boolean(),
7
+ isLiteral: z.boolean(),
8
+ });
9
+ function countRegexLineMatches(regex, line) {
10
+ regex.lastIndex = 0;
11
+ let count = 0;
12
+ while (regex.exec(line) !== null) {
13
+ count++;
14
+ if (regex.lastIndex === 0)
15
+ regex.lastIndex++;
16
+ }
17
+ return count;
18
+ }
19
+ function escapeLiteral(pattern) {
20
+ return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ }
22
+ function buildRegexPattern(pattern, options) {
23
+ const escaped = options.isLiteral ? escapeLiteral(pattern) : pattern;
24
+ return options.wholeWord ? `\\b${escaped}\\b` : escaped;
25
+ }
26
+ export function validatePattern(pattern, options) {
27
+ if (options.isLiteral && pattern.length === 0)
28
+ return;
29
+ if (options.isLiteral && !options.wholeWord)
30
+ return;
31
+ const final = buildRegexPattern(pattern, options);
32
+ if (!safeRegex(final)) {
33
+ throw new Error(`Potentially unsafe regular expression (ReDoS risk): ${pattern}`);
34
+ }
35
+ }
36
+ function buildLiteralMatcher(pattern, options) {
37
+ if (!options.caseSensitive) {
38
+ const final = escapeLiteral(pattern);
39
+ const regex = new RegExp(final, 'gi');
40
+ return (line) => countRegexLineMatches(regex, line);
41
+ }
42
+ // Fast path for case-sensitive literal
43
+ const needle = pattern;
44
+ if (needle.length === 0)
45
+ return () => 0;
46
+ return (line) => {
47
+ if (line.length === 0)
48
+ return 0;
49
+ let count = 0;
50
+ let pos = line.indexOf(needle);
51
+ while (pos !== -1) {
52
+ count++;
53
+ pos = line.indexOf(needle, pos + needle.length);
54
+ }
55
+ return count;
56
+ };
57
+ }
58
+ function buildRegexMatcher(final, caseSensitive) {
59
+ const regex = new RE2(final, caseSensitive ? 'g' : 'gi');
60
+ return (line) => countRegexLineMatches(regex, line);
61
+ }
62
+ export function buildMatcher(pattern, options) {
63
+ if (options.isLiteral && pattern.length === 0)
64
+ return () => 0;
65
+ if (options.isLiteral && !options.wholeWord) {
66
+ // fast path for simple literal search
67
+ return buildLiteralMatcher(pattern, options);
68
+ }
69
+ const final = buildRegexPattern(pattern, options);
70
+ validatePattern(pattern, options); // Re-validate to be safe
71
+ return buildRegexMatcher(final, options.caseSensitive);
72
+ }
@@ -2,7 +2,9 @@ import { parentPort, threadId, workerData } from 'node:worker_threads';
2
2
  import { formatUnknownErrorMessage } from '../errors.js';
3
3
  import { isProbablyBinary } from '../fs-helpers.js';
4
4
  import { startPerfMeasure } from '../observability.js';
5
- import { buildMatcher, scanFileInWorker } from './search-content.js';
5
+ import { scanFileInWorker } from './search-content.js';
6
+ import { buildMatcher } from './search-matcher.js';
7
+ import {} from './search-matcher.js';
6
8
  const matcherCache = new Map();
7
9
  const MAX_MATCHER_CACHE_SIZE = 100;
8
10
  function getMatcherCacheKey(pattern, options) {
@@ -1,6 +1,6 @@
1
1
  import * as path from 'node:path';
2
2
  import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_SEARCH_TIMEOUT_MS, } from '../constants.js';
3
- import { createTimedAbortSignal } from '../fs-helpers.js';
3
+ import { withTimedAbortSignal } from '../fs-helpers.js';
4
4
  import { toPosixPath } from '../path-format.js';
5
5
  import { isSensitivePath } from '../path-policy.js';
6
6
  import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
@@ -194,17 +194,16 @@ export function formatTreeAscii(tree) {
194
194
  }
195
195
  export async function treeDirectory(dirPath, options = {}) {
196
196
  const normalized = normalizeOptions(options);
197
- const { signal, cleanup } = createTimedAbortSignal(options.signal, normalized.timeoutMs);
198
- const root = await validateExistingDirectory(dirPath, signal);
199
- const rootNormalized = normalizePath(root);
200
- const rootDirectories = [rootNormalized];
201
- const accessDeps = {
202
- normalizePath,
203
- isPathWithinDirectories,
204
- isSensitivePath,
205
- validateSymlinkPath: validateExistingPathDetailed,
206
- };
207
- try {
197
+ return withTimedAbortSignal(options.signal, normalized.timeoutMs, async (signal) => {
198
+ const root = await validateExistingDirectory(dirPath, signal);
199
+ const rootNormalized = normalizePath(root);
200
+ const rootDirectories = [rootNormalized];
201
+ const accessDeps = {
202
+ normalizePath,
203
+ isPathWithinDirectories,
204
+ isSensitivePath,
205
+ validateSymlinkPath: validateExistingPathDetailed,
206
+ };
208
207
  const excludePatterns = normalized.includeIgnored
209
208
  ? []
210
209
  : DEFAULT_EXCLUDE_PATTERNS;
@@ -262,8 +261,5 @@ export async function treeDirectory(dirPath, options = {}) {
262
261
  truncated,
263
262
  totalEntries,
264
263
  };
265
- }
266
- finally {
267
- cleanup();
268
- }
264
+ });
269
265
  }
@@ -7,6 +7,7 @@ export declare function createTimedAbortSignal(baseSignal: AbortSignal | undefin
7
7
  signal: AbortSignal;
8
8
  cleanup: () => void;
9
9
  };
10
+ export declare function withTimedAbortSignal<T>(baseSignal: AbortSignal | undefined, timeoutMs: number | undefined, run: (signal: AbortSignal) => Promise<T>): Promise<T>;
10
11
  interface ParallelResult<R> {
11
12
  results: R[];
12
13
  errors: {
@@ -110,6 +110,15 @@ export function createTimedAbortSignal(baseSignal, timeoutMs) {
110
110
  }
111
111
  return createNoopSignal();
112
112
  }
113
+ export async function withTimedAbortSignal(baseSignal, timeoutMs, run) {
114
+ const { signal, cleanup } = createTimedAbortSignal(baseSignal, timeoutMs);
115
+ try {
116
+ return await run(signal);
117
+ }
118
+ finally {
119
+ cleanup();
120
+ }
121
+ }
113
122
  function createNoopSignal() {
114
123
  return { signal: SHARED_NOOP_SIGNAL, cleanup: () => { } };
115
124
  }
@@ -0,0 +1,3 @@
1
+ export declare function mergeOptions<T extends object>(defaults: T, overrides: Partial<T>): T;
2
+ export declare function omitOptionKeys<T extends object, K extends keyof T>(input: T, keys: readonly K[]): Omit<T, K>;
3
+ export declare function setIfDefined<T extends object, K extends keyof T>(target: T, key: K, value: T[K] | undefined): void;
@@ -0,0 +1,15 @@
1
+ export function mergeOptions(defaults, overrides) {
2
+ return { ...defaults, ...overrides };
3
+ }
4
+ export function omitOptionKeys(input, keys) {
5
+ const output = { ...input };
6
+ for (const key of keys) {
7
+ Reflect.deleteProperty(output, key);
8
+ }
9
+ return output;
10
+ }
11
+ export function setIfDefined(target, key, value) {
12
+ if (value !== undefined) {
13
+ target[key] = value;
14
+ }
15
+ }
@@ -8,6 +8,7 @@ import { McpError } from './errors.js';
8
8
  */
9
9
  export declare function normalizePath(p: string): string;
10
10
  export declare function getAllowedDirectories(): string[];
11
+ export declare function isAllowedDirectoryRoot(normalizedPath: string): boolean;
11
12
  export declare function isPathWithinDirectories(normalizedPath: string, allowedDirs: readonly string[]): boolean;
12
13
  export declare function setAllowedDirectoriesResolved(dirs: readonly string[], signal?: AbortSignal): Promise<void>;
13
14
  export declare function getReservedDeviceNameForPath(requestedPath: string): string | undefined;
@@ -124,6 +124,13 @@ function setAllowedDirectoriesState(primary, expanded) {
124
124
  export function getAllowedDirectories() {
125
125
  return [...allowedDirectoriesExpanded];
126
126
  }
127
+ export function isAllowedDirectoryRoot(normalizedPath) {
128
+ for (const dir of allowedDirectoriesExpanded) {
129
+ if (isSamePath(normalizedPath, dir))
130
+ return true;
131
+ }
132
+ return false;
133
+ }
127
134
  function getAllowedDirectoriesForRelativeResolution() {
128
135
  return allowedDirectoriesPrimary.length > 0
129
136
  ? allowedDirectoriesPrimary
@@ -0,0 +1,11 @@
1
+ export interface ProgressPayload {
2
+ current: number;
3
+ total?: number;
4
+ }
5
+ export type ProgressCallback = ((progress: ProgressPayload) => void) | undefined;
6
+ export interface PeriodicProgressOptions {
7
+ total?: number;
8
+ throttleModulo?: number;
9
+ force?: boolean;
10
+ }
11
+ export declare function reportPeriodicProgress(onProgress: ProgressCallback, current: number, options?: PeriodicProgressOptions): void;
@@ -0,0 +1,13 @@
1
+ export function reportPeriodicProgress(onProgress, current, options = {}) {
2
+ if (!onProgress || current === 0)
3
+ return;
4
+ const throttleModulo = options.throttleModulo ?? 1;
5
+ const force = options.force ?? false;
6
+ if (!force && throttleModulo > 1 && current % throttleModulo !== 0) {
7
+ return;
8
+ }
9
+ onProgress({
10
+ current,
11
+ ...(options.total !== undefined ? { total: options.total } : {}),
12
+ });
13
+ }
@@ -48,6 +48,9 @@ async function handleApplyPatch(args, signal) {
48
48
  if (patched === false) {
49
49
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch application failed. The file content may have changed or patch context is insufficient. Generate a fresh patch via diff_files against the current file, then retry. If differences are minor, enable fuzzy matching with the fuzzFactor parameter.');
50
50
  }
51
+ if (patched === content) {
52
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch had no effect — the file content is unchanged after applying. The patch may not match the current file content. Generate a fresh patch via diff_files and retry.');
53
+ }
51
54
  if (args.dryRun) {
52
55
  return buildToolResponse('Dry run successful. Patch can be applied.', {
53
56
  ok: true,
@@ -8,8 +8,9 @@ import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations
8
8
  import { globEntries } from '../lib/file-operations/glob-engine.js';
9
9
  import { assertNotAborted, withAbort } from '../lib/fs-helpers.js';
10
10
  import { validateExistingPath } from '../lib/path-validation.js';
11
+ import { reportPeriodicProgress } from '../lib/progress-reporting.js';
11
12
  import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
12
- import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
+ import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
14
  import { registerToolTaskIfAvailable } from './task-support.js';
14
15
  const WINDOWS_PATH_SEPARATOR = /\\/gu;
15
16
  export const CALCULATE_HASH_TOOL = {
@@ -54,13 +55,6 @@ function updateCompositeHash(hasher, pathLengthBytes, relativePath, fileHash) {
54
55
  hasher.update(relativePathBytes);
55
56
  hasher.update(fileHash);
56
57
  }
57
- function reportHashProgress(onProgress, current, force = false) {
58
- if (!onProgress || current === 0)
59
- return;
60
- if (!force && current % 25 !== 0)
61
- return;
62
- onProgress({ current });
63
- }
64
58
  async function hashDirectory(dirPath, options = {}) {
65
59
  const { signal, onProgress } = options;
66
60
  const gitignoreMatcher = await loadRootGitignore(dirPath, signal);
@@ -102,9 +96,12 @@ async function hashDirectory(dirPath, options = {}) {
102
96
  }));
103
97
  entries.push(...batchResults);
104
98
  filesHashed += batchResults.length;
105
- reportHashProgress(onProgress, filesHashed);
99
+ reportPeriodicProgress(onProgress, filesHashed, { throttleModulo: 25 });
106
100
  }
107
- reportHashProgress(onProgress, filesHashed, true);
101
+ reportPeriodicProgress(onProgress, filesHashed, {
102
+ throttleModulo: 25,
103
+ force: true,
104
+ });
108
105
  assertNotAborted(signal);
109
106
  // Sort by path with byte-wise semantics for deterministic ordering.
110
107
  entries.sort(comparePaths);
@@ -141,7 +138,10 @@ async function handleCalculateHash(args, signal, onProgress) {
141
138
  else {
142
139
  // Hash single file
143
140
  const hash = await hashFile(validPath, 'hex', signal);
144
- reportHashProgress(onProgress, 1, true);
141
+ reportPeriodicProgress(onProgress, 1, {
142
+ throttleModulo: 25,
143
+ force: true,
144
+ });
145
145
  return buildToolResponse(hash, {
146
146
  ok: true,
147
147
  path: validPath,
@@ -171,7 +171,7 @@ export function registerCalculateHashTool(server, options = {}) {
171
171
  const result = await handleCalculateHash(args, signal, progressWithMessage);
172
172
  const sc = result.structuredContent;
173
173
  const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
174
- const finalCurrent = Math.max(totalFiles + 1, progress.getCurrent() + 1);
174
+ const finalCurrent = resolveFinalProgressCurrent(progress, totalFiles + 1);
175
175
  let suffix;
176
176
  if (!sc.ok) {
177
177
  suffix = 'failed';
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { ErrorCode, isNodeError } from '../lib/errors.js';
3
+ import { ErrorCode, isNodeError, McpError } from '../lib/errors.js';
4
4
  import { withAbort } from '../lib/fs-helpers.js';
5
- import { validatePathForWrite } from '../lib/path-validation.js';
5
+ import { isAllowedDirectoryRoot, validatePathForWrite, } from '../lib/path-validation.js';
6
6
  import { DeleteFileInputSchema, DeleteFileOutputSchema } from '../schemas.js';
7
7
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -20,6 +20,9 @@ export const DELETE_FILE_TOOL = {
20
20
  };
21
21
  async function handleDeleteFile(args, signal) {
22
22
  const validPath = await validatePathForWrite(args.path, signal);
23
+ if (isAllowedDirectoryRoot(validPath)) {
24
+ throw new McpError(ErrorCode.E_ACCESS_DENIED, `Deleting a workspace root directory is not allowed: ${args.path}`);
25
+ }
23
26
  let stats;
24
27
  try {
25
28
  stats = await withAbort(fs.lstat(validPath), signal);
@@ -3,7 +3,7 @@ import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, DEFAULT_SEARCH_TIMEOUT_MS, } from '..
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { readMultipleFiles } from '../lib/file-operations/read-multiple-files.js';
5
5
  import { ReadMultipleFilesInputSchema, ReadMultipleFilesOutputSchema, } from '../schemas.js';
6
- import { buildBatchPathContext, buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildBatchCompletionSuffix, buildBatchPathContext, buildResourceLink, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  export const READ_MULTIPLE_FILES_TOOL = {
9
9
  name: 'read_many',
@@ -20,23 +20,6 @@ export const READ_MULTIPLE_FILES_TOOL = {
20
20
  'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
21
21
  ],
22
22
  };
23
- function buildReadManyCompletionSuffix(summary) {
24
- const total = summary?.total ?? 0;
25
- const failed = summary?.failed ?? 0;
26
- const succeeded = summary?.succeeded ?? 0;
27
- if (failed) {
28
- return `${succeeded}/${total} read, ${failed} failed`;
29
- }
30
- const label = total === 1 ? 'file' : 'files';
31
- return `${total} ${label} read`;
32
- }
33
- function createReadManyProgressCallbacks(extra, context, totalPaths) {
34
- const progress = createToolProgressSession(extra, `🕮 read_many: ${context}`);
35
- const onReadComplete = () => {
36
- progress.increment((current) => `🕮 read_many: ${context} [${current}/${totalPaths} read]`);
37
- };
38
- return { progress, onReadComplete };
39
- }
40
23
  function toStructuredReadManyResult(result) {
41
24
  const structured = {
42
25
  path: result.path,
@@ -152,13 +135,18 @@ export function registerReadMultipleFilesTool(server, options = {}) {
152
135
  context: { path: primaryPath },
153
136
  run: async (signal) => {
154
137
  const context = buildBatchPathContext(args.paths, 'files');
155
- const { progress, onReadComplete } = createReadManyProgressCallbacks(extra, context, args.paths.length);
138
+ const { progress, onItemComplete } = createBatchProgressCallbacks(extra, {
139
+ toolLabel: '🕮 read_many',
140
+ context,
141
+ totalItems: args.paths.length,
142
+ itemVerb: 'read',
143
+ });
156
144
  try {
157
- const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onReadComplete);
145
+ const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onItemComplete);
158
146
  const sc = result.structuredContent;
159
- const suffix = buildReadManyCompletionSuffix(sc.summary);
147
+ const suffix = buildBatchCompletionSuffix(sc.summary, 'files read', 'file read');
160
148
  const total = sc.summary?.total ?? 0;
161
- const finalCurrent = Math.max(total, progress.getCurrent() + 1);
149
+ const finalCurrent = resolveFinalProgressCurrent(progress, total);
162
150
  progress.complete(`🕮 read_many: ${context} • ${suffix}`, finalCurrent);
163
151
  return result;
164
152
  }
@@ -8,8 +8,9 @@ import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.j
8
8
  import { globEntries } from '../lib/file-operations/glob-engine.js';
9
9
  import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
10
10
  import { validateExistingPath, validatePathForWrite, } from '../lib/path-validation.js';
11
+ import { reportPeriodicProgress } from '../lib/progress-reporting.js';
11
12
  import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
12
- import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
+ import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
14
  import { registerToolTaskIfAvailable } from './task-support.js';
14
15
  export const SEARCH_AND_REPLACE_TOOL = {
15
16
  name: 'search_and_replace',
@@ -204,13 +205,6 @@ function createReplacementMatcher(args) {
204
205
  }
205
206
  return createLiteralReplacementMatcher(args.searchPattern);
206
207
  }
207
- function reportReplaceProgress(onProgress, current, force = false) {
208
- if (current === 0)
209
- return;
210
- if (!force && current % 25 !== 0)
211
- return;
212
- onProgress({ current });
213
- }
214
208
  export async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
215
209
  const maxFileSize = MAX_TEXT_FILE_SIZE;
216
210
  const root = await resolveSearchRoot(args.path, signal);
@@ -233,14 +227,19 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
233
227
  concurrency: REPLACE_CONCURRENCY,
234
228
  onEntry: () => {
235
229
  summary.processedFiles++;
236
- reportReplaceProgress(onProgress, summary.processedFiles);
230
+ reportPeriodicProgress(onProgress, summary.processedFiles, {
231
+ throttleModulo: 25,
232
+ });
237
233
  },
238
234
  runEntry: async (entryPath) => processEntry(entryPath, {
239
235
  dryRun: args.dryRun,
240
236
  returnDiff: args.returnDiff ?? false,
241
237
  }, args.replacement, matcher, maxFileSize, signal, summary),
242
238
  });
243
- reportReplaceProgress(onProgress, summary.processedFiles, true);
239
+ reportPeriodicProgress(onProgress, summary.processedFiles, {
240
+ throttleModulo: 25,
241
+ force: true,
242
+ });
244
243
  const failureSuffix = summary.failedFiles > 0 ? ` (${summary.failedFiles} failed)` : '';
245
244
  return buildToolResponse(`Found ${summary.totalMatches} matches in ${summary.filesChanged} files${failureSuffix}.${args.dryRun ? ' (Dry run)' : ''}`, {
246
245
  ok: true,
@@ -279,7 +278,7 @@ export function registerSearchAndReplaceTool(server, options = {}) {
279
278
  try {
280
279
  const result = await handleSearchAndReplace(args, signal, progressWithMessage);
281
280
  const sc = result.structuredContent;
282
- const finalCurrent = Math.max((sc.processedFiles ?? 0) + 1, progress.getCurrent() + 1);
281
+ const finalCurrent = resolveFinalProgressCurrent(progress, (sc.processedFiles ?? 0) + 1);
283
282
  const matchWord = (sc.matches ?? 0) === 1 ? 'match' : 'matches';
284
283
  const fileWord = (sc.filesChanged ?? 0) === 1 ? 'file' : 'files';
285
284
  let endSuffix = `${sc.matches ?? 0} ${matchWord} in ${sc.filesChanged ?? 0} ${fileWord}`;
@@ -5,7 +5,7 @@ import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
5
5
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
6
6
  import { searchContent } from '../lib/file-operations/search-content.js';
7
7
  import { SearchContentInputSchema, SearchContentOutputSchema, } from '../schemas.js';
8
- import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
9
9
  import { registerToolTaskIfAvailable } from './task-support.js';
10
10
  const MAX_INLINE_MATCHES = parseInt(process.env['FS_CONTEXT_MAX_INLINE_MATCHES'] ?? '', 10) || 50;
11
11
  export const SEARCH_CONTENT_TOOL = {
@@ -242,7 +242,7 @@ export function registerSearchContentTool(server, options = {}) {
242
242
  suffix += ' [truncated — max files]';
243
243
  }
244
244
  }
245
- const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progress.getCurrent() + 1);
245
+ const finalCurrent = resolveFinalProgressCurrent(progress, (sc.filesScanned ?? 0) + 1);
246
246
  progress.complete(`🔎︎ grep: ${pattern} • ${suffix}`, finalCurrent);
247
247
  return result;
248
248
  }
@@ -123,7 +123,18 @@ export interface ToolProgressSession {
123
123
  fail: (message: string, minimumCurrent?: number) => void;
124
124
  getCurrent: () => number;
125
125
  }
126
+ export interface BatchProgressCallbacks {
127
+ progress: ToolProgressSession;
128
+ onItemComplete: () => void;
129
+ }
126
130
  export declare function createToolProgressSession(extra: ToolExtra, startMessage: string): ToolProgressSession;
131
+ export declare function createBatchProgressCallbacks(extra: ToolExtra, params: {
132
+ toolLabel: string;
133
+ context: string;
134
+ totalItems: number;
135
+ itemVerb: string;
136
+ }): BatchProgressCallbacks;
137
+ export declare function resolveFinalProgressCurrent(progress: ToolProgressSession, ...candidates: number[]): number;
127
138
  export declare function wrapToolHandler<Args, Result>(handler: (args: Args, extra: ToolExtra) => Promise<ToolResult<Result>>, options: {
128
139
  guard?: (() => boolean) | undefined;
129
140
  progressMessage?: (args: Args) => string;
@@ -142,3 +153,8 @@ export declare function resolvePathOrRoot(pathValue: string | undefined): string
142
153
  export declare function encodeOffsetCursor(offset: number): string;
143
154
  export declare function decodeOffsetCursor(cursor: string): number;
144
155
  export declare function buildBatchPathContext(paths: readonly string[], unitLabel?: string): string;
156
+ export declare function buildBatchCompletionSuffix(summary: {
157
+ total?: number;
158
+ failed?: number;
159
+ succeeded?: number;
160
+ } | undefined, successWord: string, singularWord?: string): string;
@@ -293,6 +293,15 @@ export function createToolProgressSession(extra, startMessage) {
293
293
  cursor = value;
294
294
  return cursor;
295
295
  };
296
+ const finishProgress = (message, minimumCurrent) => {
297
+ const finalCurrent = Math.max(cursor + 1, minimumCurrent ?? 1, 1);
298
+ notifyProgress(extra, {
299
+ current: finalCurrent,
300
+ total: finalCurrent,
301
+ ...(message !== undefined ? { message } : {}),
302
+ });
303
+ cursor = finalCurrent;
304
+ };
296
305
  return {
297
306
  update: ({ current, total, message }) => {
298
307
  const normalized = setCursor(current);
@@ -309,27 +318,27 @@ export function createToolProgressSession(extra, startMessage) {
309
318
  message: messageForCurrent(next),
310
319
  });
311
320
  },
312
- complete: (message, minimumCurrent) => {
313
- const finalCurrent = Math.max(cursor + 1, minimumCurrent ?? 1, 1);
314
- notifyProgress(extra, {
315
- current: finalCurrent,
316
- total: finalCurrent,
317
- message,
318
- });
319
- cursor = finalCurrent;
320
- },
321
- fail: (message, minimumCurrent) => {
322
- const finalCurrent = Math.max(cursor + 1, minimumCurrent ?? 1, 1);
323
- notifyProgress(extra, {
324
- current: finalCurrent,
325
- total: finalCurrent,
326
- message,
327
- });
328
- cursor = finalCurrent;
329
- },
321
+ complete: finishProgress,
322
+ fail: finishProgress,
330
323
  getCurrent: () => cursor,
331
324
  };
332
325
  }
326
+ export function createBatchProgressCallbacks(extra, params) {
327
+ const progress = createToolProgressSession(extra, `${params.toolLabel}: ${params.context}`);
328
+ const onItemComplete = () => {
329
+ progress.increment((current) => `${params.toolLabel}: ${params.context} [${current}/${params.totalItems} ${params.itemVerb}]`);
330
+ };
331
+ return { progress, onItemComplete };
332
+ }
333
+ export function resolveFinalProgressCurrent(progress, ...candidates) {
334
+ let finalCurrent = progress.getCurrent() + 1;
335
+ for (const candidate of candidates) {
336
+ if (candidate > finalCurrent) {
337
+ finalCurrent = candidate;
338
+ }
339
+ }
340
+ return finalCurrent;
341
+ }
333
342
  async function withProgress(message, extra, run, getCompletionMessage) {
334
343
  if (!canReportProgress(extra)) {
335
344
  return run();
@@ -430,3 +439,13 @@ export function buildBatchPathContext(paths, unitLabel = 'paths') {
430
439
  : '';
431
440
  return `${paths.length} ${normalizedLabel} [${first}${extraPaths}]`;
432
441
  }
442
+ export function buildBatchCompletionSuffix(summary, successWord, singularWord) {
443
+ const total = summary?.total ?? 0;
444
+ const failed = summary?.failed ?? 0;
445
+ const succeeded = summary?.succeeded ?? 0;
446
+ if (failed) {
447
+ return `${succeeded}/${total} ${successWord}, ${failed} failed`;
448
+ }
449
+ const word = total === 1 && singularWord ? singularWord : successWord;
450
+ return `${total} ${word}`;
451
+ }
@@ -3,7 +3,7 @@ import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { getMultipleFileInfo } from '../lib/file-operations/file-info.js';
5
5
  import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
6
- import { buildBatchPathContext, buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildBatchCompletionSuffix, buildBatchPathContext, buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  export const GET_MULTIPLE_FILE_INFO_TOOL = {
9
9
  name: 'stat_many',
@@ -15,22 +15,6 @@ export const GET_MULTIPLE_FILE_INFO_TOOL = {
15
15
  taskSupport: 'optional',
16
16
  nuances: ['Use before read/search when file size/type uncertainty exists.'],
17
17
  };
18
- function buildStatManyCompletionSuffix(summary) {
19
- const total = summary?.total ?? 0;
20
- const failed = summary?.failed ?? 0;
21
- const succeeded = summary?.succeeded ?? 0;
22
- if (failed) {
23
- return `${succeeded}/${total} OK, ${failed} failed`;
24
- }
25
- return `${total} OK`;
26
- }
27
- function createStatManyProgressCallbacks(extra, context, totalPaths) {
28
- const progress = createToolProgressSession(extra, `🕮 stat_many: ${context}`);
29
- const onProgress = () => {
30
- progress.increment((current) => `🕮 stat_many: ${context} [${current}/${totalPaths} scanned]`);
31
- };
32
- return { progress, onProgress };
33
- }
34
18
  function formatFileInfoDetail(info) {
35
19
  const lines = [
36
20
  `${info.name} (${info.type})`,
@@ -90,13 +74,18 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
90
74
  context: { path: primaryPath },
91
75
  run: async (signal) => {
92
76
  const context = buildBatchPathContext(args.paths);
93
- const { progress, onProgress } = createStatManyProgressCallbacks(extra, context, args.paths.length);
77
+ const { progress, onItemComplete } = createBatchProgressCallbacks(extra, {
78
+ toolLabel: '🕮 stat_many',
79
+ context,
80
+ totalItems: args.paths.length,
81
+ itemVerb: 'scanned',
82
+ });
94
83
  try {
95
- const result = await handleGetMultipleFileInfo(args, signal, onProgress);
84
+ const result = await handleGetMultipleFileInfo(args, signal, onItemComplete);
96
85
  const sc = result.structuredContent;
97
- const suffix = buildStatManyCompletionSuffix(sc.summary);
86
+ const suffix = buildBatchCompletionSuffix(sc.summary, 'OK');
98
87
  const total = sc.summary?.total ?? 0;
99
- const finalCurrent = Math.max(total, progress.getCurrent() + 1);
88
+ const finalCurrent = resolveFinalProgressCurrent(progress, total);
100
89
  progress.complete(`🕮 stat_many: ${context} • ${suffix}`, finalCurrent);
101
90
  return result;
102
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "mcpName": "io.github.j0hanz/filesystem-mcp",
5
5
  "description": "MCP Server that enables LLMs to interact with the local filesystem.",
6
6
  "type": "module",
@@ -35,8 +35,11 @@
35
35
  "type-check:diagnostics": "tsc --noEmit --extendedDiagnostics",
36
36
  "type-check:trace": "node -e \"require('fs').rmSync('.ts-trace',{recursive:true,force:true})\" && tsc --noEmit --generateTrace .ts-trace",
37
37
  "lint": "eslint .",
38
+ "lint:tests": "eslint src/__tests__",
38
39
  "lint:fix": "eslint . --fix",
39
40
  "test": "node scripts/tasks.mjs test",
41
+ "test:fast": "node --test --import tsx/esm src/__tests__/**/*.test.ts node-tests/**/*.test.ts",
42
+ "test:coverage": "node scripts/tasks.mjs test --coverage",
40
43
  "knip": "knip",
41
44
  "knip:fix": "knip --fix",
42
45
  "inspector": "npm run build && npx -y @modelcontextprotocol/inspector node dist/index.js ${workspaceFolder}",
@@ -71,12 +74,13 @@
71
74
  },
72
75
  "devDependencies": {
73
76
  "@eslint/js": "^10.0.1",
74
- "eslint": "^10.0.2",
75
77
  "@trivago/prettier-plugin-sort-imports": "^6.0.2",
76
78
  "@types/node": "^24",
79
+ "eslint": "^10.0.2",
77
80
  "eslint-config-prettier": "^10.1.8",
78
81
  "eslint-plugin-de-morgan": "^2.0.0",
79
82
  "eslint-plugin-depend": "^1.4.0",
83
+ "eslint-plugin-sonarjs": "^4.0.0",
80
84
  "eslint-plugin-unused-imports": "^4.4.1",
81
85
  "jscpd": "^4.0.8",
82
86
  "knip": "^5.85.0",