@j0hanz/filesystem-mcp 1.9.1 → 1.10.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 (45) hide show
  1. package/README.md +10 -10
  2. package/dist/completions.js +140 -115
  3. package/dist/lib/constants.d.ts +1 -0
  4. package/dist/lib/constants.js +2 -0
  5. package/dist/lib/file-operations/metadata.d.ts +5 -1
  6. package/dist/lib/file-operations/metadata.js +14 -1
  7. package/dist/lib/file-operations/search.d.ts +2 -0
  8. package/dist/lib/file-operations/search.js +59 -27
  9. package/dist/lib/fs-helpers.d.ts +3 -1
  10. package/dist/lib/fs-helpers.js +63 -0
  11. package/dist/lib/paths.js +79 -53
  12. package/dist/lib/resource-store.d.ts +2 -0
  13. package/dist/lib/resource-store.js +58 -17
  14. package/dist/prompts.d.ts +2 -0
  15. package/dist/prompts.js +51 -0
  16. package/dist/resources/generated-instructions.js +36 -9
  17. package/dist/resources/tool-catalog.js +30 -7
  18. package/dist/resources/tool-info.d.ts +4 -0
  19. package/dist/resources/tool-info.js +21 -3
  20. package/dist/resources/workflows.js +17 -5
  21. package/dist/schemas.d.ts +36 -1
  22. package/dist/schemas.js +73 -3
  23. package/dist/server/bootstrap.js +85 -65
  24. package/dist/tools/apply-patch.js +135 -31
  25. package/dist/tools/calculate-hash.js +13 -8
  26. package/dist/tools/create-directory.js +14 -3
  27. package/dist/tools/delete-file.js +1 -0
  28. package/dist/tools/diff-files.js +26 -8
  29. package/dist/tools/edit-file.js +11 -8
  30. package/dist/tools/list-directory.js +1 -6
  31. package/dist/tools/move-file.js +39 -7
  32. package/dist/tools/read-multiple.js +9 -1
  33. package/dist/tools/read.js +38 -6
  34. package/dist/tools/replace-in-files.js +72 -25
  35. package/dist/tools/roots.js +1 -0
  36. package/dist/tools/search-content.js +76 -48
  37. package/dist/tools/search-files.js +6 -7
  38. package/dist/tools/shared.d.ts +2 -1
  39. package/dist/tools/shared.js +36 -20
  40. package/dist/tools/stat-many.js +1 -1
  41. package/dist/tools/stat.js +4 -0
  42. package/dist/tools/task-support.js +4 -12
  43. package/dist/tools/tree.js +4 -0
  44. package/dist/tools/write-file.js +4 -2
  45. package/package.json +17 -8
@@ -1,4 +1,5 @@
1
1
  import * as path from 'node:path';
2
+ import { createHash } from 'node:crypto';
2
3
  import { DEFAULT_SEARCH_TIMEOUT_MS, MAX_TEXT_FILE_SIZE, } from '../lib/constants.js';
3
4
  import { ErrorCode } from '../lib/errors.js';
4
5
  import { readFile } from '../lib/fs-helpers.js';
@@ -17,6 +18,7 @@ export const READ_FILE_TOOL = {
17
18
  nuances: [
18
19
  'Large content is externalized to `filesystem-mcp://result/{id}` and preview is returned inline.',
19
20
  ],
21
+ taskSupport: 'optional',
20
22
  };
21
23
  async function handleReadFile(args, signal, resourceStore) {
22
24
  const options = {
@@ -27,6 +29,9 @@ async function handleReadFile(args, signal, resourceStore) {
27
29
  if (args.head !== undefined) {
28
30
  options.head = args.head;
29
31
  }
32
+ if (args.tail !== undefined) {
33
+ options.tail = args.tail;
34
+ }
30
35
  if (args.startLine !== undefined) {
31
36
  options.startLine = args.startLine;
32
37
  }
@@ -46,11 +51,18 @@ async function handleReadFile(args, signal, resourceStore) {
46
51
  ? { totalLines: result.totalLines }
47
52
  : {}),
48
53
  ...(result.head !== undefined ? { head: result.head } : {}),
54
+ ...(result.tail !== undefined ? { tail: result.tail } : {}),
49
55
  ...(result.startLine !== undefined ? { startLine: result.startLine } : {}),
50
56
  ...(result.endLine !== undefined ? { endLine: result.endLine } : {}),
57
+ ...(result.linesRead !== undefined ? { linesRead: result.linesRead } : {}),
51
58
  ...(result.hasMoreLines ? { hasMoreLines: result.hasMoreLines } : {}),
52
59
  };
53
60
  const externalized = maybeExternalizeTextContent(resourceStore, result.content, { name: `read:${path.basename(args.path)}`, mimeType: 'text/plain' });
61
+ if (args.includeHash) {
62
+ structured.contentHash = createHash('sha256')
63
+ .update(result.content, 'utf-8')
64
+ .digest('hex');
65
+ }
54
66
  if (!externalized) {
55
67
  return buildToolResponse(result.content, structured);
56
68
  }
@@ -72,6 +84,7 @@ async function handleReadFile(args, signal, resourceStore) {
72
84
  name: entry.name,
73
85
  mimeType: entry.mimeType,
74
86
  description: 'Full file contents',
87
+ expiresAt: entry.expiresAt,
75
88
  }),
76
89
  ]);
77
90
  }
@@ -90,8 +103,12 @@ export function registerReadFileTool(server, options = {}) {
90
103
  const name = path.basename(args.path);
91
104
  if (args.startLine !== undefined) {
92
105
  const end = args.endLine ?? '…';
93
- return `🕮 read: ${name} [${args.startLine}-${end}]`;
106
+ return `🕮 read: ${name} [lines ${args.startLine}–${end}]`;
94
107
  }
108
+ if (args.head !== undefined)
109
+ return `🕮 read: ${name} [head ${args.head}]`;
110
+ if (args.tail !== undefined)
111
+ return `🕮 read: ${name} [tail ${args.tail}]`;
95
112
  return `🕮 read: ${name}`;
96
113
  },
97
114
  completionMessage: (args, result) => {
@@ -101,11 +118,26 @@ export function registerReadFileTool(server, options = {}) {
101
118
  const sc = result.structuredContent;
102
119
  if (!sc.ok)
103
120
  return `🕮 read: ${name} • failed`;
104
- if (sc.hasMoreLines)
105
- return `🕮 read: ${name} • truncated [${sc.totalLines ?? '?'} lines]`;
106
- if (sc.startLine !== undefined)
107
- return `🕮 read: ${name} • lines ${sc.startLine}–${sc.endLine ?? '?'}`;
108
- return `🕮 read: ${name} • ${sc.totalLines ?? '?'} lines`;
121
+ const lines = sc.linesRead ?? sc.totalLines;
122
+ if (sc.startLine !== undefined) {
123
+ const end = sc.linesRead
124
+ ? sc.startLine + sc.linesRead - 1
125
+ : (sc.endLine ?? '…');
126
+ return `🕮 read: ${name} • lines ${sc.startLine}–${end}`;
127
+ }
128
+ if (sc.head !== undefined) {
129
+ return sc.hasMoreLines
130
+ ? `🕮 read: ${name} • first ${lines ?? sc.head} lines`
131
+ : `🕮 read: ${name} • ${lines ?? sc.head} lines`;
132
+ }
133
+ if (sc.tail !== undefined) {
134
+ return sc.hasMoreLines
135
+ ? `🕮 read: ${name} • last ${lines ?? sc.tail} lines`
136
+ : `🕮 read: ${name} • ${lines ?? sc.tail} lines`;
137
+ }
138
+ if (sc.truncated)
139
+ return `🕮 read: ${name} • truncated [${String(lines)} lines]`;
140
+ return `🕮 read: ${name} • ${String(lines)} lines`;
109
141
  },
110
142
  });
111
143
  const validatedHandler = withValidatedArgs(ReadFileInputSchema, wrappedHandler);
@@ -34,6 +34,7 @@ const MAX_FAILURES = 20;
34
34
  const REPLACE_CONCURRENCY = Math.min(PARALLEL_CONCURRENCY, 8);
35
35
  const MAX_CHANGED_FILES = 100;
36
36
  const MAX_DIFF_SIZE = 20 * 1024; // 20KB limit for diff output
37
+ const DIFF_APPEND_BUFFER = 1024;
37
38
  function recordFailure(failures, failure) {
38
39
  if (failures.length >= MAX_FAILURES)
39
40
  return;
@@ -47,9 +48,10 @@ function recordChangedFile(summary, filePath, matchCount) {
47
48
  }
48
49
  summary.changedFilesTruncated = true;
49
50
  }
50
- function createRegexMatcher(pattern) {
51
+ function createRegexMatcher(pattern, caseSensitive) {
52
+ const flags = caseSensitive ? 'g' : 'gi';
51
53
  try {
52
- return new RE2(pattern, 'g');
54
+ return new RE2(pattern, flags);
53
55
  }
54
56
  catch (error) {
55
57
  throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
@@ -73,7 +75,12 @@ function createRegexReplacementMatcher(regex) {
73
75
  };
74
76
  return { count, replace };
75
77
  }
76
- function createLiteralReplacementMatcher(searchPattern) {
78
+ function createLiteralReplacementMatcher(searchPattern, caseSensitive) {
79
+ if (!caseSensitive) {
80
+ const escaped = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
81
+ const regex = new RE2(escaped, 'gi');
82
+ return createRegexReplacementMatcher(regex);
83
+ }
77
84
  const count = (content) => {
78
85
  let matchCount = 0;
79
86
  let pos = content.indexOf(searchPattern);
@@ -90,7 +97,8 @@ function createLiteralReplacementMatcher(searchPattern) {
90
97
  function formatFileTooLargeError(filePath, size, maxFileSize) {
91
98
  return `File too large: ${filePath} (${size} bytes > ${maxFileSize} bytes)`;
92
99
  }
93
- async function processEntry(entryPath, options, replacement, matcher, maxFileSize, signal, summary) {
100
+ async function processEntry(entryPath, context) {
101
+ const { options, replacement, matcher, maxFileSize, signal, summary } = context;
94
102
  let validPath;
95
103
  try {
96
104
  validPath = await validatePathForWrite(entryPath, signal);
@@ -123,14 +131,12 @@ async function processEntry(entryPath, options, replacement, matcher, maxFileSiz
123
131
  summary.filesChanged++;
124
132
  recordChangedFile(summary, validPath, matchCount);
125
133
  const newContent = matcher.replace(content, replacement);
126
- if ((options.dryRun || options.returnDiff) &&
127
- summary.diff.length < MAX_DIFF_SIZE) {
128
- const patch = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
129
- // Only append if it won't exceed the limit too much
130
- if (summary.diff.length + patch.length <= MAX_DIFF_SIZE + 1024) {
131
- summary.diff += patch;
132
- }
133
- }
134
+ maybeAppendPatchDiff(summary, {
135
+ filePath: validPath,
136
+ originalContent: content,
137
+ updatedContent: newContent,
138
+ includeDiff: options.dryRun || options.returnDiff,
139
+ });
134
140
  if (!options.dryRun) {
135
141
  await atomicWriteFile(validPath, newContent, {
136
142
  encoding: 'utf-8',
@@ -147,9 +153,27 @@ async function processEntry(entryPath, options, replacement, matcher, maxFileSiz
147
153
  });
148
154
  }
149
155
  }
156
+ function maybeAppendPatchDiff(summary, params) {
157
+ if (!params.includeDiff)
158
+ return;
159
+ if (summary.diff.length >= MAX_DIFF_SIZE) {
160
+ summary.diffTruncated = true;
161
+ return;
162
+ }
163
+ const patch = createTwoFilesPatch(path.basename(params.filePath), path.basename(params.filePath), params.originalContent, params.updatedContent, 'Original', 'Modified');
164
+ if (summary.diff.length + patch.length <=
165
+ MAX_DIFF_SIZE + DIFF_APPEND_BUFFER) {
166
+ summary.diff += patch;
167
+ return;
168
+ }
169
+ summary.diffTruncated = true;
170
+ }
150
171
  async function processEntriesConcurrently(entries, options) {
151
172
  const pending = new Set();
152
- const { signal, concurrency, onEntry, runEntry } = options;
173
+ const seen = new Set();
174
+ const { signal, concurrency, maxEntries, onEntry, runEntry } = options;
175
+ let dispatched = 0;
176
+ let stoppedByLimit = false;
153
177
  const waitForSlot = async () => {
154
178
  if (pending.size < concurrency)
155
179
  return;
@@ -158,8 +182,16 @@ async function processEntriesConcurrently(entries, options) {
158
182
  for await (const entry of entries) {
159
183
  if (signal?.aborted)
160
184
  break;
185
+ if (maxEntries !== undefined && dispatched >= maxEntries) {
186
+ stoppedByLimit = true;
187
+ break;
188
+ }
189
+ if (seen.has(entry.path))
190
+ continue;
191
+ seen.add(entry.path);
161
192
  await waitForSlot();
162
193
  onEntry();
194
+ dispatched++;
163
195
  const task = runEntry(entry.path);
164
196
  pending.add(task);
165
197
  void task.finally(() => {
@@ -169,6 +201,7 @@ async function processEntriesConcurrently(entries, options) {
169
201
  if (pending.size > 0) {
170
202
  await Promise.allSettled([...pending]);
171
203
  }
204
+ return { stoppedByLimit };
172
205
  }
173
206
  function createReplaceSummary(root) {
174
207
  return {
@@ -181,6 +214,7 @@ function createReplaceSummary(root) {
181
214
  changedFiles: [],
182
215
  changedFilesTruncated: false,
183
216
  diff: '',
217
+ diffTruncated: false,
184
218
  };
185
219
  }
186
220
  async function resolveSearchRoot(pathValue, signal) {
@@ -194,14 +228,14 @@ function createReplacementRegex(args) {
194
228
  if (!safeRegex(args.searchPattern)) {
195
229
  throw new McpError(ErrorCode.E_INVALID_INPUT, `Unsafe regex pattern: ${args.searchPattern}`);
196
230
  }
197
- return createRegexMatcher(args.searchPattern);
231
+ return createRegexMatcher(args.searchPattern, args.caseSensitive);
198
232
  }
199
233
  function createReplacementMatcher(args) {
200
234
  const regex = createReplacementRegex(args);
201
235
  if (regex) {
202
236
  return createRegexReplacementMatcher(regex);
203
237
  }
204
- return createLiteralReplacementMatcher(args.searchPattern);
238
+ return createLiteralReplacementMatcher(args.searchPattern, args.caseSensitive);
205
239
  }
206
240
  export async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
207
241
  const maxFileSize = MAX_TEXT_FILE_SIZE;
@@ -220,9 +254,10 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
220
254
  suppressErrors: true,
221
255
  });
222
256
  const summary = createReplaceSummary(root);
223
- await processEntriesConcurrently(entries, {
257
+ const { stoppedByLimit } = await processEntriesConcurrently(entries, {
224
258
  signal,
225
259
  concurrency: REPLACE_CONCURRENCY,
260
+ ...(args.maxFiles !== undefined ? { maxEntries: args.maxFiles } : {}),
226
261
  onEntry: () => {
227
262
  summary.processedFiles++;
228
263
  reportPeriodicProgress(onProgress, summary.processedFiles, {
@@ -230,10 +265,20 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
230
265
  });
231
266
  },
232
267
  runEntry: async (entryPath) => processEntry(entryPath, {
233
- dryRun: args.dryRun,
234
- returnDiff: args.returnDiff ?? false,
235
- }, args.replacement, matcher, maxFileSize, signal, summary),
268
+ options: {
269
+ dryRun: args.dryRun,
270
+ returnDiff: args.returnDiff ?? false,
271
+ },
272
+ replacement: args.replacement,
273
+ matcher,
274
+ maxFileSize,
275
+ signal,
276
+ summary,
277
+ }),
236
278
  });
279
+ if (stoppedByLimit) {
280
+ summary.stoppedReason = 'maxFiles';
281
+ }
237
282
  reportPeriodicProgress(onProgress, summary.processedFiles, {
238
283
  throttleModulo: 25,
239
284
  force: true,
@@ -253,6 +298,10 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
253
298
  ...((args.dryRun || args.returnDiff) && summary.diff
254
299
  ? { diff: summary.diff }
255
300
  : {}),
301
+ ...(summary.diffTruncated ? { diffTruncated: true } : {}),
302
+ ...(summary.stoppedReason
303
+ ? { stoppedReason: summary.stoppedReason }
304
+ : {}),
256
305
  dryRun: args.dryRun,
257
306
  });
258
307
  }
@@ -265,12 +314,12 @@ export function registerSearchAndReplaceTool(server, options = {}) {
265
314
  run: async (signal) => {
266
315
  const dryLabel = args.dryRun ? ' [dry run]' : '';
267
316
  const context = `"${args.searchPattern}" in ${args.filePattern}${dryLabel}`;
268
- const progress = createToolProgressSession(extra, `🛠 search_and_replace: ${context}`);
317
+ const progress = createToolProgressSession(extra, `🛠 replace: ${context}`);
269
318
  const progressWithMessage = ({ current, total, }) => {
270
319
  progress.update({
271
320
  current,
272
321
  ...(total !== undefined ? { total } : {}),
273
- message: `🛠 search_and_replace: ${args.searchPattern} [${current} files processed]`,
322
+ message: `🛠 replace: ${args.searchPattern} [${current} files]`,
274
323
  });
275
324
  };
276
325
  try {
@@ -282,13 +331,11 @@ export function registerSearchAndReplaceTool(server, options = {}) {
282
331
  let endSuffix = `${sc.matches ?? 0} ${matchWord} in ${sc.filesChanged ?? 0} ${fileWord}`;
283
332
  if (sc.failedFiles)
284
333
  endSuffix += `, ${sc.failedFiles} failed`;
285
- if (sc.dryRun)
286
- endSuffix += ' [dry run]';
287
- progress.complete(`🛠 search_and_replace: ${context} • ${endSuffix}`, finalCurrent);
334
+ progress.complete(`🛠 replace: ${context} • ${endSuffix}`, finalCurrent);
288
335
  return result;
289
336
  }
290
337
  catch (error) {
291
- progress.fail(`🛠 search_and_replace: ${context} • failed`);
338
+ progress.fail(`🛠 replace: ${context} • failed`);
292
339
  throw error;
293
340
  }
294
341
  },
@@ -12,6 +12,7 @@ export const LIST_ALLOWED_DIRECTORIES_TOOL = {
12
12
  outputSchema: ListAllowedDirectoriesOutputSchema,
13
13
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
14
14
  nuances: ['Returns absolute paths of all allowed directories.'],
15
+ taskSupport: 'optional',
15
16
  };
16
17
  function buildTextRoots(dirs) {
17
18
  if (dirs.length === 0) {
@@ -34,36 +34,62 @@ function assertValidRegexPattern(pattern) {
34
34
  throw new McpError(ErrorCode.E_INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
35
35
  }
36
36
  }
37
+ function findColumnOffset(content, pattern, isRegex, caseSensitive) {
38
+ try {
39
+ if (isRegex) {
40
+ const flags = caseSensitive ? '' : 'i';
41
+ const regex = new RE2(pattern, flags);
42
+ const match = regex.exec(content);
43
+ return match ? match.index : undefined;
44
+ }
45
+ if (caseSensitive) {
46
+ const idx = content.indexOf(pattern);
47
+ return idx >= 0 ? idx : undefined;
48
+ }
49
+ const lowerContent = content.toLowerCase();
50
+ const lowerPattern = pattern.toLowerCase();
51
+ const idx = lowerContent.indexOf(lowerPattern);
52
+ return idx >= 0 ? idx : undefined;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
37
58
  function buildSearchTextResult(result, normalizedMatches) {
38
59
  const { summary } = result;
39
60
  if (normalizedMatches.length === 0)
40
61
  return 'No matches';
41
62
  let truncatedReason;
42
63
  if (summary.truncated) {
43
- if (summary.stoppedReason === 'timeout') {
44
- truncatedReason = 'timeout';
45
- }
46
- else if (summary.stoppedReason === 'maxFiles') {
47
- truncatedReason = `max files (${summary.filesScanned})`;
48
- }
49
- else {
50
- truncatedReason = `max results (${summary.matches})`;
51
- }
64
+ truncatedReason = resolveTruncatedReason(summary);
52
65
  }
53
66
  const summaryOptions = {
54
67
  truncated: summary.truncated,
55
68
  ...(truncatedReason ? { truncatedReason } : {}),
56
69
  };
57
- const lines = [`Found ${normalizedMatches.length}:`];
58
- for (const match of normalizedMatches) {
70
+ return (buildMatchListText(`Found ${normalizedMatches.length}:`, normalizedMatches) + formatOperationSummary(summaryOptions));
71
+ }
72
+ function resolveTruncatedReason(summary) {
73
+ if (summary.stoppedReason === 'timeout')
74
+ return 'timeout';
75
+ if (summary.stoppedReason === 'maxFiles') {
76
+ return `max files (${summary.filesScanned})`;
77
+ }
78
+ return `max results (${summary.matches})`;
79
+ }
80
+ function buildMatchListText(heading, matches) {
81
+ const lines = [heading];
82
+ for (const match of matches) {
59
83
  lines.push(formatSearchMatchLine(match));
60
84
  }
61
- return joinLines(lines) + formatOperationSummary(summaryOptions);
85
+ return joinLines(lines);
62
86
  }
63
- function buildSearchMatchPayload(match) {
87
+ function buildSearchMatchPayload(match, context) {
88
+ const column = findColumnOffset(match.content, context.pattern, context.isRegex, context.caseSensitive);
64
89
  return {
65
90
  file: match.relativeFile,
66
91
  line: match.line,
92
+ ...(column !== undefined ? { column } : {}),
67
93
  content: match.content,
68
94
  matchCount: match.matchCount,
69
95
  ...(match.contextBefore ? { contextBefore: [...match.contextBefore] } : {}),
@@ -74,9 +100,9 @@ function formatSearchMatchLine(match) {
74
100
  const lineNum = String(match.line).padStart(4);
75
101
  return ` ${match.relativeFile}:${lineNum}: ${match.content}`;
76
102
  }
77
- function buildStructuredSearchResult(result, normalizedMatches, options) {
103
+ function buildStructuredSearchResult(result, normalizedMatches, options, context) {
78
104
  const { summary } = result;
79
- const matches = normalizedMatches.map((match) => buildSearchMatchPayload(match));
105
+ const matches = normalizedMatches.map((match) => buildSearchMatchPayload(match, context));
80
106
  return {
81
107
  ok: true,
82
108
  patternType: options.patternType,
@@ -141,6 +167,7 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
141
167
  contextLines: args.contextLines,
142
168
  maxResults: args.maxResults,
143
169
  isLiteral: !args.isRegex,
170
+ multiline: args.multiline,
144
171
  };
145
172
  if (signal) {
146
173
  options.signal = signal;
@@ -159,16 +186,21 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
159
186
  throw error;
160
187
  }
161
188
  const normalizedMatches = normalizeSearchMatches(result);
189
+ const searchContext = {
190
+ pattern: args.pattern,
191
+ isRegex: args.isRegex,
192
+ caseSensitive: args.caseSensitive,
193
+ };
162
194
  const structuredFull = buildStructuredSearchResult(result, normalizedMatches, {
163
195
  patternType,
164
196
  caseSensitive: args.caseSensitive,
165
- });
197
+ }, searchContext);
166
198
  const needsExternalize = normalizedMatches.length > MAX_INLINE_MATCHES;
167
199
  if (!resourceStore || !needsExternalize) {
168
200
  return buildToolResponse(buildSearchTextResult(result, normalizedMatches), structuredFull);
169
201
  }
170
202
  const previewMatches = normalizedMatches.slice(0, MAX_INLINE_MATCHES);
171
- const previewPayload = previewMatches.map((match) => buildSearchMatchPayload(match));
203
+ const previewPayload = previewMatches.map((match) => buildSearchMatchPayload(match, searchContext));
172
204
  const previewStructured = {
173
205
  ...structuredFull,
174
206
  matches: previewPayload,
@@ -181,19 +213,14 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
181
213
  text: JSON.stringify(structuredFull),
182
214
  });
183
215
  previewStructured.resourceUri = entry.uri;
184
- const textLines = [
185
- `Found ${normalizedMatches.length} (showing first ${MAX_INLINE_MATCHES}):`,
186
- ];
187
- for (const match of previewMatches) {
188
- textLines.push(formatSearchMatchLine(match));
189
- }
190
- const text = joinLines(textLines);
216
+ const text = buildMatchListText(`Found ${normalizedMatches.length} (showing first ${MAX_INLINE_MATCHES}):`, previewMatches);
191
217
  return buildToolResponse(text, previewStructured, [
192
218
  buildResourceLink({
193
219
  uri: entry.uri,
194
220
  name: entry.name,
195
221
  mimeType: entry.mimeType,
196
222
  description: 'Full grep results as JSON (structuredContent)',
223
+ expiresAt: entry.expiresAt,
197
224
  }),
198
225
  ]);
199
226
  }
@@ -205,13 +232,12 @@ export function registerSearchContentTool(server, options = {}) {
205
232
  run: async (signal) => {
206
233
  const scope = args.filePattern;
207
234
  const { pattern } = args;
208
- const progress = createToolProgressSession(extra, `🔎︎ grep: ${pattern} in ${scope}`);
235
+ const progress = createToolProgressSession(extra, `🔎︎ grep: ${pattern}`);
209
236
  const progressWithMessage = ({ current, total, }) => {
210
- const fileWord = current === 1 ? 'file' : 'files';
211
237
  progress.update({
212
238
  current,
213
239
  ...(total !== undefined ? { total } : {}),
214
- message: `🔎︎ grep: ${pattern} [${current} ${fileWord} scanned]`,
240
+ message: `🔎︎ grep: ${pattern} [${current} files]`,
215
241
  });
216
242
  };
217
243
  try {
@@ -220,37 +246,39 @@ export function registerSearchContentTool(server, options = {}) {
220
246
  const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
221
247
  const filesMatched = sc.ok ? (sc.filesMatched ?? 0) : 0;
222
248
  const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
223
- let suffix;
224
- if (count === 0) {
225
- suffix = `No matches in ${scope}`;
226
- }
227
- else {
228
- const matchWord = count === 1 ? 'match' : 'matches';
229
- const fileInfo = filesMatched > 0
230
- ? ` in ${filesMatched} ${filesMatched === 1 ? 'file' : 'files'}`
231
- : '';
232
- suffix = `${count} ${matchWord}${fileInfo}`;
233
- if (stoppedReason === 'timeout') {
234
- suffix += ' [stopped — timeout]';
235
- }
236
- else if (stoppedReason === 'maxResults') {
237
- suffix += ' [truncated — max results]';
238
- }
239
- else if (stoppedReason === 'maxFiles') {
240
- suffix += ' [truncated — max files]';
241
- }
242
- }
249
+ const suffix = buildCompletionSuffix({
250
+ count,
251
+ filesMatched,
252
+ scope,
253
+ stoppedReason,
254
+ });
243
255
  const finalCurrent = resolveFinalProgressCurrent(progress, (sc.filesScanned ?? 0) + 1);
244
256
  progress.complete(`🔎︎ grep: ${pattern} • ${suffix}`, finalCurrent);
245
257
  return result;
246
258
  }
247
259
  catch (error) {
248
- progress.fail(`🔎︎ grep: ${pattern} in ${scope} • failed`);
260
+ progress.fail(`🔎︎ grep: ${pattern} • failed`);
249
261
  throw error;
250
262
  }
251
263
  },
252
264
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? '.'),
253
265
  });
266
+ function buildCompletionSuffix(params) {
267
+ if (params.count === 0) {
268
+ return `No matches in ${params.scope}`;
269
+ }
270
+ const matchWord = params.count === 1 ? 'match' : 'matches';
271
+ const fileWord = params.filesMatched === 1 ? 'file' : 'files';
272
+ const reasonLabels = {
273
+ timeout: 'timeout',
274
+ maxResults: 'max results',
275
+ maxFiles: 'max files',
276
+ };
277
+ const reasonSuffix = params.stoppedReason !== undefined
278
+ ? ` [${reasonLabels[params.stoppedReason]}]`
279
+ : '';
280
+ return `${params.count} ${matchWord} in ${params.filesMatched} ${fileWord}${reasonSuffix}`;
281
+ }
254
282
  const { isInitialized } = options;
255
283
  const wrappedHandler = wrapToolHandler(handler, {
256
284
  guard: isInitialized,
@@ -113,17 +113,16 @@ export function registerSearchFilesTool(server, options = {}) {
113
113
  let progressCursor = 0;
114
114
  notifyProgress(extra, {
115
115
  current: 0,
116
- message: `🔎︎ find: ${context}`,
116
+ message: `🔎︎ find: ${pattern}`,
117
117
  });
118
118
  const baseReporter = createProgressReporter(extra);
119
119
  const progressWithMessage = ({ current, total, }) => {
120
120
  if (current > progressCursor)
121
121
  progressCursor = current;
122
- const fileWord = current === 1 ? 'file' : 'files';
123
122
  baseReporter({
124
123
  current,
125
124
  ...(total !== undefined ? { total } : {}),
126
- message: `🔎︎ find: ${pattern} [${current} ${fileWord} scanned]`,
125
+ message: `🔎︎ find: ${pattern} [${current} files]`,
127
126
  });
128
127
  };
129
128
  try {
@@ -133,18 +132,18 @@ export function registerSearchFilesTool(server, options = {}) {
133
132
  const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
134
133
  let suffix;
135
134
  if (count === 0) {
136
- suffix = `No matches in ${scopeLabel}`;
135
+ suffix = 'No matches';
137
136
  }
138
137
  else {
139
138
  suffix = `${count} ${count === 1 ? 'match' : 'matches'}`;
140
139
  if (stoppedReason === 'timeout') {
141
- suffix += ' [stopped — timeout]';
140
+ suffix += ' [timeout]';
142
141
  }
143
142
  else if (stoppedReason === 'maxResults') {
144
- suffix += ' [truncated — max results]';
143
+ suffix += ' [max results]';
145
144
  }
146
145
  else if (stoppedReason === 'maxFiles') {
147
- suffix += ' [truncated — max files]';
146
+ suffix += ' [max files]';
148
147
  }
149
148
  }
150
149
  const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progressCursor + 1);
@@ -37,6 +37,7 @@ export declare function buildResourceLink(params: {
37
37
  name: string;
38
38
  mimeType?: string;
39
39
  description?: string;
40
+ expiresAt?: string;
40
41
  }): ContentBlock;
41
42
  export declare function buildToolResponse<T>(text: string, structuredContent: T, extraContent?: ContentBlock[]): {
42
43
  content: ContentBlock[];
@@ -128,7 +129,7 @@ export interface BatchProgressCallbacks {
128
129
  progress: ToolProgressSession;
129
130
  onItemComplete: () => void;
130
131
  }
131
- export declare function createToolProgressSession(extra: ToolExtra, startMessage: string): ToolProgressSession;
132
+ export declare function createToolProgressSession(extra: ToolExtra, startMessage: string, initialTotal?: number): ToolProgressSession;
132
133
  export declare function createBatchProgressCallbacks(extra: ToolExtra, params: {
133
134
  toolLabel: string;
134
135
  context: string;