@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.
Files changed (44) hide show
  1. package/dist/lib/file-operations/common.d.ts +42 -0
  2. package/dist/lib/file-operations/common.js +87 -0
  3. package/dist/lib/file-operations/file-info.js +13 -19
  4. package/dist/lib/file-operations/glob-engine.d.ts +1 -6
  5. package/dist/lib/file-operations/glob-engine.js +0 -9
  6. package/dist/lib/file-operations/glob-helpers.d.ts +18 -0
  7. package/dist/lib/file-operations/glob-helpers.js +23 -0
  8. package/dist/lib/file-operations/list-directory.js +22 -46
  9. package/dist/lib/file-operations/read-multiple-files.js +11 -19
  10. package/dist/lib/file-operations/search-content.d.ts +1 -8
  11. package/dist/lib/file-operations/search-content.js +126 -204
  12. package/dist/lib/file-operations/search-files.js +48 -94
  13. package/dist/lib/file-operations/search-matcher.d.ts +10 -0
  14. package/dist/lib/file-operations/search-matcher.js +72 -0
  15. package/dist/lib/file-operations/search-worker.js +3 -1
  16. package/dist/lib/file-operations/tree.d.ts +2 -2
  17. package/dist/lib/file-operations/tree.js +26 -42
  18. package/dist/lib/fs-helpers.d.ts +1 -0
  19. package/dist/lib/fs-helpers.js +9 -0
  20. package/dist/lib/option-utils.d.ts +3 -0
  21. package/dist/lib/option-utils.js +15 -0
  22. package/dist/lib/path-validation.d.ts +1 -0
  23. package/dist/lib/path-validation.js +7 -0
  24. package/dist/lib/progress-reporting.d.ts +11 -0
  25. package/dist/lib/progress-reporting.js +13 -0
  26. package/dist/prompts.js +3 -3
  27. package/dist/resources/generated-instructions.js +14 -14
  28. package/dist/resources/tool-catalog.js +9 -9
  29. package/dist/resources/tool-info.js +5 -5
  30. package/dist/resources/workflows.js +17 -17
  31. package/dist/schemas.js +21 -90
  32. package/dist/server/bootstrap.js +2 -2
  33. package/dist/tools/apply-patch.js +3 -0
  34. package/dist/tools/calculate-hash.js +12 -12
  35. package/dist/tools/delete-file.js +5 -2
  36. package/dist/tools/list-directory.js +3 -21
  37. package/dist/tools/read-multiple.js +11 -21
  38. package/dist/tools/replace-in-files.js +10 -11
  39. package/dist/tools/search-content.js +2 -2
  40. package/dist/tools/search-files.js +3 -21
  41. package/dist/tools/shared.d.ts +19 -0
  42. package/dist/tools/shared.js +64 -18
  43. package/dist/tools/stat-many.js +11 -22
  44. package/package.json +6 -2
package/dist/schemas.js CHANGED
@@ -18,6 +18,9 @@ function isSafeGlobPattern(value) {
18
18
  const MAX_PATH_LENGTH = 4096;
19
19
  const DESC_PATH_ROOT = 'Base directory (default: root). Absolute path required if multiple roots exist. Examples: "src", "src/components"';
20
20
  const DESC_PATH_REQUIRED = 'Absolute path to file or directory. Examples: "src/index.ts", "README.md"';
21
+ function defaultFalseBoolean(description) {
22
+ return z.boolean().optional().default(false).describe(description);
23
+ }
21
24
  const PathSchemaBase = z
22
25
  .string()
23
26
  .max(MAX_PATH_LENGTH, `Path too long (max ${MAX_PATH_LENGTH} chars)`);
@@ -109,16 +112,8 @@ const OperationSummarySchema = z.strictObject({
109
112
  });
110
113
  export const ListDirectoryInputSchema = z.strictObject({
111
114
  path: OptionalPathSchema.describe(DESC_PATH_ROOT),
112
- includeHidden: z
113
- .boolean()
114
- .optional()
115
- .default(false)
116
- .describe('Include hidden items (starting with .)'),
117
- includeIgnored: z
118
- .boolean()
119
- .optional()
120
- .default(false)
121
- .describe('Include ignored items (node_modules, .git, etc).'),
115
+ includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
116
+ includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, .git, etc).'),
122
117
  maxDepth: z
123
118
  .number()
124
119
  .int({ error: 'Must be integer' })
@@ -143,11 +138,7 @@ export const ListDirectoryInputSchema = z.strictObject({
143
138
  .max(1000, 'Max 1000 chars')
144
139
  .optional()
145
140
  .describe('Optional glob pattern filter (e.g. "**/*.ts")'),
146
- includeSymlinkTargets: z
147
- .boolean()
148
- .optional()
149
- .default(false)
150
- .describe('Resolve and include symlink targets in results'),
141
+ includeSymlinkTargets: defaultFalseBoolean('Resolve and include symlink targets in results'),
151
142
  cursor: z
152
143
  .string()
153
144
  .optional()
@@ -174,16 +165,8 @@ export const SearchFilesInputSchema = z.strictObject({
174
165
  .optional()
175
166
  .default(DEFAULT_SEARCH_RESULTS)
176
167
  .describe(`Max results (1-${MAX_SEARCH_RESULTS}). Default: ${DEFAULT_SEARCH_RESULTS}`),
177
- includeIgnored: z
178
- .boolean()
179
- .optional()
180
- .default(false)
181
- .describe('Include ignored items (node_modules, etc).'),
182
- includeHidden: z
183
- .boolean()
184
- .optional()
185
- .default(false)
186
- .describe('Include hidden items (starting with .)'),
168
+ includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, etc).'),
169
+ includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
187
170
  sortBy: SearchFilesSortSchema.optional()
188
171
  .default('path')
189
172
  .describe('Sort by path, name, size, or modified'),
@@ -217,16 +200,8 @@ export const TreeInputSchema = z.strictObject({
217
200
  .optional()
218
201
  .default(DEFAULT_TREE_ENTRIES)
219
202
  .describe(`Max entries. Default: ${DEFAULT_TREE_ENTRIES}`),
220
- includeHidden: z
221
- .boolean()
222
- .optional()
223
- .default(false)
224
- .describe('Include hidden items (starting with .)'),
225
- includeIgnored: z
226
- .boolean()
227
- .optional()
228
- .default(false)
229
- .describe('Include ignored items. Disables .gitignore.'),
203
+ includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
204
+ includeIgnored: defaultFalseBoolean('Include ignored items. Disables .gitignore.'),
230
205
  });
231
206
  export const SearchContentInputSchema = z.strictObject({
232
207
  path: OptionalPathSchema.describe(DESC_PATH_ROOT),
@@ -235,21 +210,9 @@ export const SearchContentInputSchema = z.strictObject({
235
210
  .min(1, 'Pattern required')
236
211
  .max(1000, 'Max 1000 chars')
237
212
  .describe('Literal text to search for by default; treated as RE2 regex when isRegex is true.'),
238
- isRegex: z
239
- .boolean()
240
- .optional()
241
- .default(false)
242
- .describe('Treat pattern as a RE2 regular expression. RE2 does not support lookahead, lookbehind, or backreferences.'),
243
- caseSensitive: z
244
- .boolean()
245
- .optional()
246
- .default(false)
247
- .describe('Case-sensitive matching (default: false — searches are case-insensitive).'),
248
- wholeWord: z
249
- .boolean()
250
- .optional()
251
- .default(false)
252
- .describe('Match whole words only'),
213
+ isRegex: defaultFalseBoolean('Treat pattern as a RE2 regular expression. RE2 does not support lookahead, lookbehind, or backreferences.'),
214
+ caseSensitive: defaultFalseBoolean('Case-sensitive matching (default: false — searches are case-insensitive).'),
215
+ wholeWord: defaultFalseBoolean('Match whole words only'),
253
216
  contextLines: z
254
217
  .number()
255
218
  .int({ error: 'Must be integer' })
@@ -273,16 +236,8 @@ export const SearchContentInputSchema = z.strictObject({
273
236
  .optional()
274
237
  .default('**/*')
275
238
  .describe('Glob for candidate files (e.g. "**/*.ts")'),
276
- includeHidden: z
277
- .boolean()
278
- .optional()
279
- .default(false)
280
- .describe('Include hidden items (starting with .)'),
281
- includeIgnored: z
282
- .boolean()
283
- .optional()
284
- .default(false)
285
- .describe('Include ignored items (node_modules, etc).'),
239
+ includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
240
+ includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, etc).'),
286
241
  });
287
242
  export const ReadFileInputSchema = z
288
243
  .strictObject({
@@ -511,16 +466,8 @@ export const EditFileInputSchema = z.strictObject({
511
466
  }))
512
467
  .min(1, 'Min 1 edit required')
513
468
  .describe('List of replacements to apply sequentially. Each edit replaces the first occurrence of oldText.'),
514
- dryRun: z
515
- .boolean()
516
- .optional()
517
- .default(false)
518
- .describe('Preview edits without writing. Check unmatchedEdits in the response to verify all oldText values were found.'),
519
- ignoreWhitespace: z
520
- .boolean()
521
- .optional()
522
- .default(false)
523
- .describe('Ignore leading/trailing whitespace and treat all whitespace sequences as equivalent when matching oldText.'),
469
+ dryRun: defaultFalseBoolean('Preview edits without writing. Check unmatchedEdits in the response to verify all oldText values were found.'),
470
+ ignoreWhitespace: defaultFalseBoolean('Ignore leading/trailing whitespace and treat all whitespace sequences as equivalent when matching oldText.'),
524
471
  });
525
472
  export const EditFileOutputSchema = z.strictObject({
526
473
  ok: z.boolean(),
@@ -562,16 +509,8 @@ export const MoveFileOutputSchema = z.strictObject({
562
509
  });
563
510
  export const DeleteFileInputSchema = z.strictObject({
564
511
  path: RequiredPathSchema.describe(DESC_PATH_REQUIRED),
565
- recursive: z
566
- .boolean()
567
- .optional()
568
- .default(false)
569
- .describe('Delete non-empty directories'),
570
- ignoreIfNotExists: z
571
- .boolean()
572
- .optional()
573
- .default(false)
574
- .describe('No error if missing'),
512
+ recursive: defaultFalseBoolean('Delete non-empty directories'),
513
+ ignoreIfNotExists: defaultFalseBoolean('No error if missing'),
575
514
  });
576
515
  export const DeleteFileOutputSchema = z.strictObject({
577
516
  ok: z.boolean(),
@@ -665,16 +604,8 @@ export const SearchAndReplaceInputSchema = z.strictObject({
665
604
  .min(1, 'Search pattern required')
666
605
  .describe('Text to search for. Matched literally by default; treated as RE2 regex when isRegex is true.'),
667
606
  replacement: z.string().describe('Replacement text'),
668
- isRegex: z
669
- .boolean()
670
- .optional()
671
- .default(false)
672
- .describe('Treat searchPattern as a RE2 regular expression. Supports capture group references ($1, $2) in replacement.'),
673
- dryRun: z
674
- .boolean()
675
- .optional()
676
- .default(false)
677
- .describe('Preview matches without writing. Check changedFiles and matches in the response before committing.'),
607
+ isRegex: defaultFalseBoolean('Treat searchPattern as a RE2 regular expression. Supports capture group references ($1, $2) in replacement.'),
608
+ dryRun: defaultFalseBoolean('Preview matches without writing. Check changedFiles and matches in the response before committing.'),
678
609
  includeHidden: z
679
610
  .boolean()
680
611
  .optional()
@@ -66,8 +66,8 @@ export async function createServer(options = {}) {
66
66
  if (serverInstructions) {
67
67
  serverConfig.instructions =
68
68
  'filesystem-mcp: Secure local filesystem MCP server. ' +
69
- 'Always begin with: roots ls/find stat read. Never guess paths. ' +
70
- 'Full reference: read the internal://instructions resource or invoke the get-help prompt.';
69
+ 'Start with: roots -> ls/find -> stat -> read. Never guess paths. ' +
70
+ 'For full guidance, read internal://instructions or run the get-help prompt.';
71
71
  }
72
72
  const server = new McpServer(withDefaultIcons({
73
73
  name: 'filesystem-mcp',
@@ -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);
@@ -4,7 +4,7 @@ import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { listDirectory } from '../lib/file-operations/list-directory.js';
6
6
  import { ListDirectoryInputSchema, ListDirectoryOutputSchema, } from '../schemas.js';
7
- import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
+ import { buildToolErrorResponse, buildToolResponse, decodeOffsetCursor, encodeOffsetCursor, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const LIST_DIRECTORY_TOOL = {
10
10
  name: 'ls',
@@ -82,27 +82,9 @@ function buildStructuredListResult(result, nextCursor) {
82
82
  ...(nextCursor !== undefined ? { nextCursor } : {}),
83
83
  };
84
84
  }
85
- function encodeCursor(offset) {
86
- return Buffer.from(JSON.stringify({ offset })).toString('base64url');
87
- }
88
- function decodeCursor(cursor) {
89
- try {
90
- const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
91
- if (typeof parsed === 'object' &&
92
- parsed !== null &&
93
- typeof parsed.offset === 'number') {
94
- const { offset } = parsed;
95
- return Number.isInteger(offset) && offset >= 0 ? offset : 0;
96
- }
97
- }
98
- catch {
99
- // ignore malformed cursor
100
- }
101
- return 0;
102
- }
103
85
  async function handleListDirectory(args, signal) {
104
86
  const dirPath = resolvePathOrRoot(args.path);
105
- const cursorOffset = args.cursor !== undefined ? decodeCursor(args.cursor) : 0;
87
+ const cursorOffset = args.cursor !== undefined ? decodeOffsetCursor(args.cursor) : 0;
106
88
  const pageSize = args.maxEntries;
107
89
  const options = {
108
90
  includeHidden: args.includeHidden,
@@ -117,7 +99,7 @@ async function handleListDirectory(args, signal) {
117
99
  const result = await listDirectory(dirPath, options);
118
100
  const displayEntries = cursorOffset > 0 ? result.entries.slice(cursorOffset) : result.entries;
119
101
  const nextCursor = result.summary.truncated && displayEntries.length > 0
120
- ? encodeCursor(cursorOffset + displayEntries.length)
102
+ ? encodeOffsetCursor(cursorOffset + displayEntries.length)
121
103
  : undefined;
122
104
  const displayResult = { ...result, entries: displayEntries };
123
105
  return buildToolResponse(buildListTextResult(displayResult, nextCursor), buildStructuredListResult(displayResult, nextCursor));
@@ -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 { 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',
@@ -134,29 +134,19 @@ export function registerReadMultipleFilesTool(server, options = {}) {
134
134
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
135
135
  context: { path: primaryPath },
136
136
  run: async (signal) => {
137
- const first = path.basename(args.paths[0] ?? '');
138
- const extraPaths = args.paths.length > 1
139
- ? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
140
- : '';
141
- const context = `${args.paths.length} files [${first}${extraPaths}]`;
142
- const progress = createToolProgressSession(extra, `🕮 read_many: ${context}`);
143
- const onReadComplete = () => {
144
- progress.increment((current) => `🕮 read_many: ${context} [${current}/${args.paths.length} read]`);
145
- };
137
+ const context = buildBatchPathContext(args.paths, 'files');
138
+ const { progress, onItemComplete } = createBatchProgressCallbacks(extra, {
139
+ toolLabel: '🕮 read_many',
140
+ context,
141
+ totalItems: args.paths.length,
142
+ itemVerb: 'read',
143
+ });
146
144
  try {
147
- const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onReadComplete);
145
+ const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onItemComplete);
148
146
  const sc = result.structuredContent;
147
+ const suffix = buildBatchCompletionSuffix(sc.summary, 'files read', 'file read');
149
148
  const total = sc.summary?.total ?? 0;
150
- const failed = sc.summary?.failed ?? 0;
151
- const succeeded = sc.summary?.succeeded ?? 0;
152
- let suffix;
153
- if (failed) {
154
- suffix = `${succeeded}/${total} read, ${failed} failed`;
155
- }
156
- else {
157
- suffix = `${total} files read`;
158
- }
159
- const finalCurrent = Math.max(total, progress.getCurrent() + 1);
149
+ const finalCurrent = resolveFinalProgressCurrent(progress, total);
160
150
  progress.complete(`🕮 read_many: ${context} • ${suffix}`, finalCurrent);
161
151
  return result;
162
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
  }
@@ -4,26 +4,8 @@ import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_SEARCH_TIMEOUT_MS, } from '../lib/con
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { searchFiles } from '../lib/file-operations/search-files.js';
6
6
  import { SearchFilesInputSchema, SearchFilesOutputSchema } from '../schemas.js';
7
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
+ import { buildToolErrorResponse, buildToolResponse, createProgressReporter, decodeOffsetCursor, encodeOffsetCursor, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
9
- function encodeCursor(offset) {
10
- return Buffer.from(JSON.stringify({ offset })).toString('base64url');
11
- }
12
- function decodeCursor(cursor) {
13
- try {
14
- const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
15
- if (typeof parsed === 'object' &&
16
- parsed !== null &&
17
- typeof parsed.offset === 'number') {
18
- const { offset } = parsed;
19
- return Number.isInteger(offset) && offset >= 0 ? offset : 0;
20
- }
21
- }
22
- catch {
23
- // ignore malformed cursor
24
- }
25
- return 0;
26
- }
27
9
  export const SEARCH_FILES_TOOL = {
28
10
  name: 'find',
29
11
  title: 'Find Files',
@@ -43,7 +25,7 @@ export const SEARCH_FILES_TOOL = {
43
25
  async function handleSearchFiles(args, signal, onProgress) {
44
26
  const basePath = resolvePathOrRoot(args.path);
45
27
  const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
46
- const cursorOffset = args.cursor !== undefined ? decodeCursor(args.cursor) : 0;
28
+ const cursorOffset = args.cursor !== undefined ? decodeOffsetCursor(args.cursor) : 0;
47
29
  const pageSize = args.maxResults;
48
30
  const fetchMax = cursorOffset + pageSize;
49
31
  const searchOptions = {
@@ -59,7 +41,7 @@ async function handleSearchFiles(args, signal, onProgress) {
59
41
  const allResults = result.results;
60
42
  const displayResults = cursorOffset > 0 ? allResults.slice(cursorOffset) : allResults;
61
43
  const nextCursor = result.summary.truncated && displayResults.length > 0
62
- ? encodeCursor(cursorOffset + displayResults.length)
44
+ ? encodeOffsetCursor(cursorOffset + displayResults.length)
63
45
  : undefined;
64
46
  const relativeResults = [];
65
47
  for (const entry of displayResults) {
@@ -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;
@@ -139,3 +150,11 @@ export declare function wrapToolHandler<Args, Result>(handler: (args: Args, extr
139
150
  * See `src/server/roots-manager.ts` for the update lifecycle.
140
151
  */
141
152
  export declare function resolvePathOrRoot(pathValue: string | undefined): string;
153
+ export declare function encodeOffsetCursor(offset: number): string;
154
+ export declare function decodeOffsetCursor(cursor: string): number;
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;
@@ -1,3 +1,4 @@
1
+ import * as path from 'node:path';
1
2
  import { channel } from 'node:diagnostics_channel';
2
3
  import { z } from 'zod';
3
4
  import { parseTrueEnvFlag } from '../lib/constants.js';
@@ -292,6 +293,15 @@ export function createToolProgressSession(extra, startMessage) {
292
293
  cursor = value;
293
294
  return cursor;
294
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
+ };
295
305
  return {
296
306
  update: ({ current, total, message }) => {
297
307
  const normalized = setCursor(current);
@@ -308,27 +318,27 @@ export function createToolProgressSession(extra, startMessage) {
308
318
  message: messageForCurrent(next),
309
319
  });
310
320
  },
311
- complete: (message, minimumCurrent) => {
312
- const finalCurrent = Math.max(cursor + 1, minimumCurrent ?? 1, 1);
313
- notifyProgress(extra, {
314
- current: finalCurrent,
315
- total: finalCurrent,
316
- message,
317
- });
318
- cursor = finalCurrent;
319
- },
320
- fail: (message, minimumCurrent) => {
321
- const finalCurrent = Math.max(cursor + 1, minimumCurrent ?? 1, 1);
322
- notifyProgress(extra, {
323
- current: finalCurrent,
324
- total: finalCurrent,
325
- message,
326
- });
327
- cursor = finalCurrent;
328
- },
321
+ complete: finishProgress,
322
+ fail: finishProgress,
329
323
  getCurrent: () => cursor,
330
324
  };
331
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
+ }
332
342
  async function withProgress(message, extra, run, getCompletionMessage) {
333
343
  if (!canReportProgress(extra)) {
334
344
  return run();
@@ -403,3 +413,39 @@ export function resolvePathOrRoot(pathValue) {
403
413
  }
404
414
  return root;
405
415
  }
416
+ export function encodeOffsetCursor(offset) {
417
+ return Buffer.from(JSON.stringify({ offset })).toString('base64url');
418
+ }
419
+ export function decodeOffsetCursor(cursor) {
420
+ try {
421
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
422
+ if (typeof parsed === 'object' &&
423
+ parsed !== null &&
424
+ typeof parsed.offset === 'number') {
425
+ const { offset } = parsed;
426
+ return Number.isInteger(offset) && offset >= 0 ? offset : 0;
427
+ }
428
+ }
429
+ catch {
430
+ // ignore malformed cursor
431
+ }
432
+ return 0;
433
+ }
434
+ export function buildBatchPathContext(paths, unitLabel = 'paths') {
435
+ const normalizedLabel = paths.length === 1 ? unitLabel.replace(/s$/i, '') : unitLabel;
436
+ const first = path.basename(paths[0] ?? '');
437
+ const extraPaths = paths.length > 1
438
+ ? `, ${path.basename(paths[1] ?? '')}${paths.length > 2 ? '…' : ''}`
439
+ : '';
440
+ return `${paths.length} ${normalizedLabel} [${first}${extraPaths}]`;
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
+ }