@j0hanz/filesystem-mcp 1.6.1 → 1.7.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.
@@ -9,7 +9,7 @@ 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
11
  import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
12
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
12
+ import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
13
  import { registerToolTaskIfAvailable } from './task-support.js';
14
14
  const WINDOWS_PATH_SEPARATOR = /\\/gu;
15
15
  export const CALCULATE_HASH_TOOL = {
@@ -158,17 +158,10 @@ export function registerCalculateHashTool(server, options = {}) {
158
158
  context: { path: args.path },
159
159
  run: async (signal) => {
160
160
  const baseName = path.basename(args.path);
161
- let progressCursor = 0;
162
- notifyProgress(extra, {
163
- current: 0,
164
- message: `🕮 calculate_hash: ${baseName}`,
165
- });
166
- const baseReporter = createProgressReporter(extra);
161
+ const progress = createToolProgressSession(extra, `🕮 calculate_hash: ${baseName}`);
167
162
  const progressWithMessage = ({ current, total, }) => {
168
- if (current > progressCursor)
169
- progressCursor = current;
170
163
  const fileWord = current === 1 ? 'file' : 'files';
171
- baseReporter({
164
+ progress.update({
172
165
  current,
173
166
  ...(total !== undefined ? { total } : {}),
174
167
  message: `🕮 calculate_hash: ${baseName} [${current} ${fileWord} hashed]`,
@@ -178,7 +171,7 @@ export function registerCalculateHashTool(server, options = {}) {
178
171
  const result = await handleCalculateHash(args, signal, progressWithMessage);
179
172
  const sc = result.structuredContent;
180
173
  const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
181
- const finalCurrent = Math.max(totalFiles + 1, progressCursor + 1);
174
+ const finalCurrent = Math.max(totalFiles + 1, progress.getCurrent() + 1);
182
175
  let suffix;
183
176
  if (!sc.ok) {
184
177
  suffix = 'failed';
@@ -189,20 +182,11 @@ export function registerCalculateHashTool(server, options = {}) {
189
182
  else {
190
183
  suffix = `${(sc.hash ?? '').slice(0, 8)}...`;
191
184
  }
192
- notifyProgress(extra, {
193
- current: finalCurrent,
194
- total: finalCurrent,
195
- message: `🕮 calculate_hash: ${baseName} • ${suffix}`,
196
- });
185
+ progress.complete(`🕮 calculate_hash: ${baseName} • ${suffix}`, finalCurrent);
197
186
  return result;
198
187
  }
199
188
  catch (error) {
200
- const finalCurrent = Math.max(progressCursor + 1, 1);
201
- notifyProgress(extra, {
202
- current: finalCurrent,
203
- total: finalCurrent,
204
- message: `🕮 calculate_hash: ${baseName} • failed`,
205
- });
189
+ progress.fail(`🕮 calculate_hash: ${baseName} • failed`);
206
190
  throw error;
207
191
  }
208
192
  },
@@ -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, createProgressReporter, executeToolWithDiagnostics, maybeExternalizeTextContent, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, 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,6 +20,33 @@ export const READ_MULTIPLE_FILES_TOOL = {
20
20
  'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
21
21
  ],
22
22
  };
23
+ function toStructuredReadManyResult(result) {
24
+ const structured = {
25
+ path: result.path,
26
+ };
27
+ if (result.content !== undefined)
28
+ structured.content = result.content;
29
+ if (result.truncated)
30
+ structured.truncated = result.truncated;
31
+ if (result.resourceUri)
32
+ structured.resourceUri = result.resourceUri;
33
+ if (result.head !== undefined)
34
+ structured.head = result.head;
35
+ if (result.startLine !== undefined)
36
+ structured.startLine = result.startLine;
37
+ if (result.endLine !== undefined)
38
+ structured.endLine = result.endLine;
39
+ if (result.hasMoreLines)
40
+ structured.hasMoreLines = result.hasMoreLines;
41
+ if (result.totalLines !== undefined)
42
+ structured.totalLines = result.totalLines;
43
+ if (result.truncationReason) {
44
+ structured.truncationReason = result.truncationReason;
45
+ }
46
+ if (result.error)
47
+ structured.error = result.error;
48
+ return structured;
49
+ }
23
50
  async function handleReadMultipleFiles(args, signal, resourceStore, onReadComplete) {
24
51
  const options = {
25
52
  ...(signal ? { signal } : {}),
@@ -70,25 +97,7 @@ async function handleReadMultipleFiles(args, signal, resourceStore, onReadComple
70
97
  }
71
98
  const structured = {
72
99
  ok: true,
73
- results: mappedResults.map((result) => ({
74
- path: result.path,
75
- ...(result.content !== undefined ? { content: result.content } : {}),
76
- ...(result.truncated ? { truncated: result.truncated } : {}),
77
- ...(result.resourceUri ? { resourceUri: result.resourceUri } : {}),
78
- ...(result.head !== undefined ? { head: result.head } : {}),
79
- ...(result.startLine !== undefined
80
- ? { startLine: result.startLine }
81
- : {}),
82
- ...(result.endLine !== undefined ? { endLine: result.endLine } : {}),
83
- ...(result.hasMoreLines ? { hasMoreLines: result.hasMoreLines } : {}),
84
- ...(result.totalLines !== undefined
85
- ? { totalLines: result.totalLines }
86
- : {}),
87
- ...(result.truncationReason
88
- ? { truncationReason: result.truncationReason }
89
- : {}),
90
- ...(result.error ? { error: result.error } : {}),
91
- })),
100
+ results: mappedResults.map((result) => toStructuredReadManyResult(result)),
92
101
  summary: {
93
102
  total: mappedResults.length,
94
103
  succeeded,
@@ -130,18 +139,9 @@ export function registerReadMultipleFilesTool(server, options = {}) {
130
139
  ? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
131
140
  : '';
132
141
  const context = `${args.paths.length} files [${first}${extraPaths}]`;
133
- let progressCursor = 0;
134
- notifyProgress(extra, {
135
- current: 0,
136
- message: `🕮 read_many: ${context}`,
137
- });
138
- const baseReporter = createProgressReporter(extra);
142
+ const progress = createToolProgressSession(extra, `🕮 read_many: ${context}`);
139
143
  const onReadComplete = () => {
140
- progressCursor++;
141
- baseReporter({
142
- current: progressCursor,
143
- message: `🕮 read_many: ${context} [${progressCursor}/${args.paths.length} read]`,
144
- });
144
+ progress.increment((current) => `🕮 read_many: ${context} [${current}/${args.paths.length} read]`);
145
145
  };
146
146
  try {
147
147
  const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onReadComplete);
@@ -156,21 +156,12 @@ export function registerReadMultipleFilesTool(server, options = {}) {
156
156
  else {
157
157
  suffix = `${total} files read`;
158
158
  }
159
- const finalCurrent = Math.max(total, progressCursor + 1);
160
- notifyProgress(extra, {
161
- current: finalCurrent,
162
- total: finalCurrent,
163
- message: `🕮 read_many: ${context} • ${suffix}`,
164
- });
159
+ const finalCurrent = Math.max(total, progress.getCurrent() + 1);
160
+ progress.complete(`🕮 read_many: ${context} • ${suffix}`, finalCurrent);
165
161
  return result;
166
162
  }
167
163
  catch (error) {
168
- const finalCurrent = Math.max(progressCursor + 1, 1);
169
- notifyProgress(extra, {
170
- current: finalCurrent,
171
- total: finalCurrent,
172
- message: `🕮 read_many: ${context} • failed`,
173
- });
164
+ progress.fail(`🕮 read_many: ${context} • failed`);
174
165
  throw error;
175
166
  }
176
167
  },
@@ -9,7 +9,7 @@ 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
11
  import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
12
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, notifyProgress, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
12
+ import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
13
  import { registerToolTaskIfAvailable } from './task-support.js';
14
14
  export const SEARCH_AND_REPLACE_TOOL = {
15
15
  name: 'search_and_replace',
@@ -56,31 +56,42 @@ function createRegexMatcher(pattern) {
56
56
  throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
57
57
  }
58
58
  }
59
- function countRegexMatches(content, regex) {
60
- regex.lastIndex = 0;
61
- let count = 0;
62
- while (regex.exec(content) !== null) {
63
- count++;
64
- if (regex.lastIndex === 0) {
65
- regex.lastIndex++;
59
+ function createRegexReplacementMatcher(regex) {
60
+ const count = (content) => {
61
+ regex.lastIndex = 0;
62
+ let matchCount = 0;
63
+ while (regex.exec(content) !== null) {
64
+ matchCount++;
65
+ if (regex.lastIndex === 0) {
66
+ regex.lastIndex++;
67
+ }
66
68
  }
67
- }
68
- return count;
69
+ return matchCount;
70
+ };
71
+ const replace = (content, replacement) => {
72
+ regex.lastIndex = 0;
73
+ return content.replace(regex, replacement);
74
+ };
75
+ return { count, replace };
69
76
  }
70
- function countLiteralMatches(content, searchPattern) {
71
- let count = 0;
72
- let pos = content.indexOf(searchPattern);
73
- const patternLength = searchPattern.length;
74
- while (pos !== -1) {
75
- count++;
76
- pos = content.indexOf(searchPattern, pos + patternLength);
77
- }
78
- return count;
77
+ function createLiteralReplacementMatcher(searchPattern) {
78
+ const count = (content) => {
79
+ let matchCount = 0;
80
+ let pos = content.indexOf(searchPattern);
81
+ const patternLength = searchPattern.length;
82
+ while (pos !== -1) {
83
+ matchCount++;
84
+ pos = content.indexOf(searchPattern, pos + patternLength);
85
+ }
86
+ return matchCount;
87
+ };
88
+ const replace = (content, replacement) => content.replaceAll(searchPattern, () => replacement);
89
+ return { count, replace };
79
90
  }
80
91
  function formatFileTooLargeError(filePath, size, maxFileSize) {
81
92
  return `File too large: ${filePath} (${size} bytes > ${maxFileSize} bytes)`;
82
93
  }
83
- async function processEntry(entryPath, args, regex, maxFileSize, signal, summary) {
94
+ async function processEntry(entryPath, options, replacement, matcher, maxFileSize, signal, summary) {
84
95
  let validPath;
85
96
  try {
86
97
  validPath = await validatePathForWrite(entryPath, signal);
@@ -107,22 +118,13 @@ async function processEntry(entryPath, args, regex, maxFileSize, signal, summary
107
118
  encoding: 'utf-8',
108
119
  signal,
109
120
  });
110
- const matchCount = args.isRegex && regex
111
- ? countRegexMatches(content, regex)
112
- : countLiteralMatches(content, args.searchPattern);
121
+ const matchCount = matcher.count(content);
113
122
  if (matchCount > 0) {
114
123
  summary.totalMatches += matchCount;
115
124
  summary.filesChanged++;
116
125
  recordChangedFile(summary, validPath, matchCount);
117
- let newContent;
118
- if (args.isRegex && regex) {
119
- regex.lastIndex = 0;
120
- newContent = content.replace(regex, args.replacement);
121
- }
122
- else {
123
- newContent = content.replaceAll(args.searchPattern, () => args.replacement);
124
- }
125
- if ((args.dryRun || args.returnDiff) &&
126
+ const newContent = matcher.replace(content, replacement);
127
+ if ((options.dryRun || options.returnDiff) &&
126
128
  summary.diff.length < MAX_DIFF_SIZE) {
127
129
  const patch = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
128
130
  // Only append if it won't exceed the limit too much
@@ -130,7 +132,7 @@ async function processEntry(entryPath, args, regex, maxFileSize, signal, summary
130
132
  summary.diff += patch;
131
133
  }
132
134
  }
133
- if (!args.dryRun) {
135
+ if (!options.dryRun) {
134
136
  await atomicWriteFile(validPath, newContent, {
135
137
  encoding: 'utf-8',
136
138
  signal,
@@ -195,6 +197,13 @@ function createReplacementRegex(args) {
195
197
  }
196
198
  return createRegexMatcher(args.searchPattern);
197
199
  }
200
+ function createReplacementMatcher(args) {
201
+ const regex = createReplacementRegex(args);
202
+ if (regex) {
203
+ return createRegexReplacementMatcher(regex);
204
+ }
205
+ return createLiteralReplacementMatcher(args.searchPattern);
206
+ }
198
207
  function reportReplaceProgress(onProgress, current, force = false) {
199
208
  if (current === 0)
200
209
  return;
@@ -205,7 +214,7 @@ function reportReplaceProgress(onProgress, current, force = false) {
205
214
  export async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
206
215
  const maxFileSize = MAX_TEXT_FILE_SIZE;
207
216
  const root = await resolveSearchRoot(args.path, signal);
208
- const regex = createReplacementRegex(args);
217
+ const matcher = createReplacementMatcher(args);
209
218
  const entries = globEntries({
210
219
  cwd: root,
211
220
  pattern: args.filePattern,
@@ -226,7 +235,10 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
226
235
  summary.processedFiles++;
227
236
  reportReplaceProgress(onProgress, summary.processedFiles);
228
237
  },
229
- runEntry: async (entryPath) => processEntry(entryPath, args, regex, maxFileSize, signal, summary),
238
+ runEntry: async (entryPath) => processEntry(entryPath, {
239
+ dryRun: args.dryRun,
240
+ returnDiff: args.returnDiff ?? false,
241
+ }, args.replacement, matcher, maxFileSize, signal, summary),
230
242
  });
231
243
  reportReplaceProgress(onProgress, summary.processedFiles, true);
232
244
  const failureSuffix = summary.failedFiles > 0 ? ` (${summary.failedFiles} failed)` : '';
@@ -256,16 +268,9 @@ export function registerSearchAndReplaceTool(server, options = {}) {
256
268
  run: async (signal) => {
257
269
  const dryLabel = args.dryRun ? ' [dry run]' : '';
258
270
  const context = `"${args.searchPattern}" in ${args.filePattern}${dryLabel}`;
259
- let progressCursor = 0;
260
- notifyProgress(extra, {
261
- current: 0,
262
- message: `🛠 search_and_replace: ${context}`,
263
- });
264
- const baseReporter = createProgressReporter(extra);
271
+ const progress = createToolProgressSession(extra, `🛠 search_and_replace: ${context}`);
265
272
  const progressWithMessage = ({ current, total, }) => {
266
- if (current > progressCursor)
267
- progressCursor = current;
268
- baseReporter({
273
+ progress.update({
269
274
  current,
270
275
  ...(total !== undefined ? { total } : {}),
271
276
  message: `🛠 search_and_replace: ${args.searchPattern} [${current} files processed]`,
@@ -274,7 +279,7 @@ export function registerSearchAndReplaceTool(server, options = {}) {
274
279
  try {
275
280
  const result = await handleSearchAndReplace(args, signal, progressWithMessage);
276
281
  const sc = result.structuredContent;
277
- const finalCurrent = Math.max((sc.processedFiles ?? 0) + 1, progressCursor + 1);
282
+ const finalCurrent = Math.max((sc.processedFiles ?? 0) + 1, progress.getCurrent() + 1);
278
283
  const matchWord = (sc.matches ?? 0) === 1 ? 'match' : 'matches';
279
284
  const fileWord = (sc.filesChanged ?? 0) === 1 ? 'file' : 'files';
280
285
  let endSuffix = `${sc.matches ?? 0} ${matchWord} in ${sc.filesChanged ?? 0} ${fileWord}`;
@@ -282,20 +287,11 @@ export function registerSearchAndReplaceTool(server, options = {}) {
282
287
  endSuffix += `, ${sc.failedFiles} failed`;
283
288
  if (sc.dryRun)
284
289
  endSuffix += ' [dry run]';
285
- notifyProgress(extra, {
286
- current: finalCurrent,
287
- total: finalCurrent,
288
- message: `🛠 search_and_replace: ${context} • ${endSuffix}`,
289
- });
290
+ progress.complete(`🛠 search_and_replace: ${context} • ${endSuffix}`, finalCurrent);
290
291
  return result;
291
292
  }
292
293
  catch (error) {
293
- const finalCurrent = Math.max(progressCursor + 1, 1);
294
- notifyProgress(extra, {
295
- current: finalCurrent,
296
- total: finalCurrent,
297
- message: `🛠 search_and_replace: ${context} • failed`,
298
- });
294
+ progress.fail(`🛠 search_and_replace: ${context} • failed`);
299
295
  throw error;
300
296
  }
301
297
  },
@@ -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, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, 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 = {
@@ -207,28 +207,16 @@ export function registerSearchContentTool(server, options = {}) {
207
207
  run: async (signal) => {
208
208
  const scope = args.filePattern;
209
209
  const { pattern } = args;
210
- let progressCursor = 0;
211
- notifyProgress(extra, {
212
- current: 0,
213
- message: `🔎︎ grep: ${pattern} in ${scope}`,
214
- });
215
- const baseReporter = createProgressReporter(extra);
210
+ const progress = createToolProgressSession(extra, `🔎︎ grep: ${pattern} in ${scope}`);
216
211
  const progressWithMessage = ({ current, total, }) => {
217
- if (current > progressCursor)
218
- progressCursor = current;
219
212
  const fileWord = current === 1 ? 'file' : 'files';
220
- baseReporter({
213
+ progress.update({
221
214
  current,
222
215
  ...(total !== undefined ? { total } : {}),
223
216
  message: `🔎︎ grep: ${pattern} [${current} ${fileWord} scanned]`,
224
217
  });
225
218
  };
226
219
  try {
227
- if (signal) {
228
- signal.addEventListener('abort', () => {
229
- console.error('searchContent signal aborted!');
230
- });
231
- }
232
220
  const result = await handleSearchContent(args, signal, options.resourceStore, progressWithMessage);
233
221
  const sc = result.structuredContent;
234
222
  const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
@@ -254,21 +242,12 @@ export function registerSearchContentTool(server, options = {}) {
254
242
  suffix += ' [truncated — max files]';
255
243
  }
256
244
  }
257
- const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progressCursor + 1);
258
- notifyProgress(extra, {
259
- current: finalCurrent,
260
- total: finalCurrent,
261
- message: `🔎︎ grep: ${pattern} • ${suffix}`,
262
- });
245
+ const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progress.getCurrent() + 1);
246
+ progress.complete(`🔎︎ grep: ${pattern} • ${suffix}`, finalCurrent);
263
247
  return result;
264
248
  }
265
249
  catch (error) {
266
- const finalCurrent = Math.max(progressCursor + 1, 1);
267
- notifyProgress(extra, {
268
- current: finalCurrent,
269
- total: finalCurrent,
270
- message: `🔎︎ grep: ${pattern} in ${scope} • failed`,
271
- });
250
+ progress.fail(`🔎︎ grep: ${pattern} in ${scope} failed`);
272
251
  throw error;
273
252
  }
274
253
  },
@@ -47,6 +47,7 @@ export type ToolResponse<T> = ReturnType<typeof buildToolResponse<T>> & {
47
47
  interface ToolErrorResponse extends Record<string, unknown> {
48
48
  content: ContentBlock[];
49
49
  isError: true;
50
+ errorCode?: string;
50
51
  }
51
52
  export type ToolResult<T> = ToolResponse<T> | ToolErrorResponse;
52
53
  export declare function withValidatedArgs<Args, Result>(schema: z.ZodType<Args>, handler: (args: Args, extra: ToolExtra) => Promise<ToolResult<Result>>): (args: unknown, extra: ToolExtra) => Promise<ToolResult<Result>>;
@@ -111,9 +112,30 @@ export declare function notifyProgress(extra: ToolExtra, progress: {
111
112
  total?: number;
112
113
  message?: string;
113
114
  }): void;
115
+ export interface ToolProgressSession {
116
+ update: (progress: {
117
+ current: number;
118
+ total?: number;
119
+ message: string;
120
+ }) => void;
121
+ increment: (messageForCurrent: (current: number) => string) => void;
122
+ complete: (message: string, minimumCurrent?: number) => void;
123
+ fail: (message: string, minimumCurrent?: number) => void;
124
+ getCurrent: () => number;
125
+ }
126
+ export declare function createToolProgressSession(extra: ToolExtra, startMessage: string): ToolProgressSession;
114
127
  export declare function wrapToolHandler<Args, Result>(handler: (args: Args, extra: ToolExtra) => Promise<ToolResult<Result>>, options: {
115
128
  guard?: (() => boolean) | undefined;
116
129
  progressMessage?: (args: Args) => string;
117
130
  completionMessage?: (args: Args, result: ToolResult<Result>) => string | undefined;
118
131
  }): (args: Args, extra?: ToolExtra) => Promise<ToolResult<Result>>;
132
+ /**
133
+ * Returns `pathValue` if non-empty; otherwise resolves to the single allowed
134
+ * directory from module-level state managed by `RootsManager`. Throws when the
135
+ * path is ambiguous (multiple roots) or when no roots are configured.
136
+ *
137
+ * NOTE: Depends on `getAllowedDirectories()` which reads module-level state
138
+ * updated by `RootsManager`. Ensure the server is initialized before calling.
139
+ * See `src/server/roots-manager.ts` for the update lifecycle.
140
+ */
119
141
  export declare function resolvePathOrRoot(pathValue: string | undefined): string;
@@ -198,6 +198,7 @@ export function buildToolErrorResponse(error, defaultCode, path) {
198
198
  return {
199
199
  content: [{ type: 'text', text }],
200
200
  isError: true,
201
+ errorCode: detailed.code,
201
202
  };
202
203
  }
203
204
  function buildNotInitializedResult() {
@@ -259,10 +260,11 @@ export function createProgressReporter(extra) {
259
260
  // out-of-order progress is undefined in the MCP spec.
260
261
  if (current <= lastProgress)
261
262
  return;
262
- // Enforce rate-limiting to prevent client flooding. Progress updates faster
263
- // than PROGRESS_RATE_LIMIT_MS are silently dropped.
263
+ // Terminal notifications always bypass the rate limit so clients reliably
264
+ // receive the final state even when updates arrive in quick succession.
265
+ const isTerminal = total !== undefined && current >= total;
264
266
  const now = Date.now();
265
- if (now - lastSentMs < PROGRESS_RATE_LIMIT_MS)
267
+ if (!isTerminal && now - lastSentMs < PROGRESS_RATE_LIMIT_MS)
266
268
  return;
267
269
  lastProgress = current;
268
270
  lastSentMs = now;
@@ -278,16 +280,66 @@ export function notifyProgress(extra, progress) {
278
280
  return;
279
281
  void reportProgress(extra, progress);
280
282
  }
283
+ export function createToolProgressSession(extra, startMessage) {
284
+ notifyProgress(extra, {
285
+ current: 0,
286
+ message: startMessage,
287
+ });
288
+ let cursor = 0;
289
+ const baseReporter = createProgressReporter(extra);
290
+ const setCursor = (value) => {
291
+ if (value > cursor)
292
+ cursor = value;
293
+ return cursor;
294
+ };
295
+ return {
296
+ update: ({ current, total, message }) => {
297
+ const normalized = setCursor(current);
298
+ baseReporter({
299
+ current: normalized,
300
+ ...(total !== undefined ? { total } : {}),
301
+ message,
302
+ });
303
+ },
304
+ increment: (messageForCurrent) => {
305
+ const next = setCursor(cursor + 1);
306
+ baseReporter({
307
+ current: next,
308
+ message: messageForCurrent(next),
309
+ });
310
+ },
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
+ },
329
+ getCurrent: () => cursor,
330
+ };
331
+ }
281
332
  async function withProgress(message, extra, run, getCompletionMessage) {
282
333
  if (!canReportProgress(extra)) {
283
334
  return run();
284
335
  }
285
336
  const total = 1;
286
- await reportProgress(extra, {
287
- current: 0,
288
- total,
289
- message,
290
- });
337
+ // Emit the start notification only when a progressToken is present; for
338
+ // task-only mode the task status is already 'working' — a zero-progress
339
+ // notification would add unnecessary overhead without client value.
340
+ if (canSendProgress(extra)) {
341
+ await reportProgress(extra, { current: 0, total, message });
342
+ }
291
343
  try {
292
344
  const result = await run();
293
345
  const endMessage = getCompletionMessage?.(result) ?? message;
@@ -326,6 +378,15 @@ export function wrapToolHandler(handler, options) {
326
378
  return maybeStripStructuredContentFromResult(result);
327
379
  };
328
380
  }
381
+ /**
382
+ * Returns `pathValue` if non-empty; otherwise resolves to the single allowed
383
+ * directory from module-level state managed by `RootsManager`. Throws when the
384
+ * path is ambiguous (multiple roots) or when no roots are configured.
385
+ *
386
+ * NOTE: Depends on `getAllowedDirectories()` which reads module-level state
387
+ * updated by `RootsManager`. Ensure the server is initialized before calling.
388
+ * See `src/server/roots-manager.ts` for the update lifecycle.
389
+ */
329
390
  export function resolvePathOrRoot(pathValue) {
330
391
  if (pathValue && pathValue.trim().length > 0)
331
392
  return pathValue;
@@ -4,7 +4,7 @@ import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { getMultipleFileInfo } from '../lib/file-operations/file-info.js';
6
6
  import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
7
- import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
+ import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const GET_MULTIPLE_FILE_INFO_TOOL = {
10
10
  name: 'stat_many',
@@ -79,18 +79,9 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
79
79
  ? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
80
80
  : '';
81
81
  const context = `${args.paths.length} paths [${first}${extraPaths}]`;
82
- let progressCursor = 0;
83
- notifyProgress(extra, {
84
- current: 0,
85
- message: `🕮 stat_many: ${context}`,
86
- });
87
- const baseReporter = createProgressReporter(extra);
82
+ const progress = createToolProgressSession(extra, `🕮 stat_many: ${context}`);
88
83
  const onProgress = () => {
89
- progressCursor++;
90
- baseReporter({
91
- current: progressCursor,
92
- message: `🕮 stat_many: ${context} [${progressCursor}/${args.paths.length} scanned]`,
93
- });
84
+ progress.increment((current) => `🕮 stat_many: ${context} [${current}/${args.paths.length} scanned]`);
94
85
  };
95
86
  try {
96
87
  const result = await handleGetMultipleFileInfo(args, signal, onProgress);
@@ -105,21 +96,12 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
105
96
  else {
106
97
  suffix = `${total} OK`;
107
98
  }
108
- const finalCurrent = Math.max(total, progressCursor + 1);
109
- notifyProgress(extra, {
110
- current: finalCurrent,
111
- total: finalCurrent,
112
- message: `🕮 stat_many: ${context} • ${suffix}`,
113
- });
99
+ const finalCurrent = Math.max(total, progress.getCurrent() + 1);
100
+ progress.complete(`🕮 stat_many: ${context} • ${suffix}`, finalCurrent);
114
101
  return result;
115
102
  }
116
103
  catch (error) {
117
- const finalCurrent = Math.max(progressCursor + 1, 1);
118
- notifyProgress(extra, {
119
- current: finalCurrent,
120
- total: finalCurrent,
121
- message: `🕮 stat_many: ${context} • failed`,
122
- });
104
+ progress.fail(`🕮 stat_many: ${context} • failed`);
123
105
  throw error;
124
106
  }
125
107
  },