@j0hanz/filesystem-mcp 1.10.0 → 1.11.1

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.
@@ -20,11 +20,23 @@ export const EDIT_FILE_TOOL = {
20
20
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
21
21
  nuances: ['Each edit applies to the output of the previous edit.'],
22
22
  gotchas: ['Unmatched `oldText` entries listed in `unmatchedEdits`.'],
23
- taskSupport: 'optional',
23
+ taskSupport: 'forbidden',
24
24
  };
25
25
  function escapeRegExp(string) {
26
26
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
27
  }
28
+ function computeDiffStats(original, modified) {
29
+ const patch = createTwoFilesPatch('a', 'b', original, modified);
30
+ let linesAdded = 0;
31
+ let linesRemoved = 0;
32
+ for (const line of patch.split('\n')) {
33
+ if (line.startsWith('+') && !line.startsWith('+++'))
34
+ linesAdded++;
35
+ else if (line.startsWith('-') && !line.startsWith('---'))
36
+ linesRemoved++;
37
+ }
38
+ return { linesAdded, linesRemoved };
39
+ }
28
40
  function applyEdits(content, edits, ignoreWhitespace) {
29
41
  let newContent = content;
30
42
  let appliedEdits = 0;
@@ -74,10 +86,15 @@ function applyEdits(content, edits, ignoreWhitespace) {
74
86
  appliedEdits += 1;
75
87
  }
76
88
  }
89
+ const { linesAdded, linesRemoved } = appliedEdits > 0
90
+ ? computeDiffStats(content, newContent)
91
+ : { linesAdded: 0, linesRemoved: 0 };
77
92
  const result = {
78
93
  content: newContent,
79
94
  appliedEdits,
80
95
  unmatchedEdits,
96
+ linesAdded,
97
+ linesRemoved,
81
98
  };
82
99
  if (minLine !== undefined && maxLine !== undefined) {
83
100
  result.lineRange = [minLine, maxLine];
@@ -92,11 +109,12 @@ export async function handleEditFile(args, signal) {
92
109
  throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for edit: ${args.path} (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, args.path, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
93
110
  }
94
111
  const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
95
- const { content: newContent, appliedEdits, unmatchedEdits, lineRange, } = applyEdits(content, args.edits, args.ignoreWhitespace);
112
+ const { content: newContent, appliedEdits, unmatchedEdits, linesAdded, linesRemoved, lineRange, } = applyEdits(content, args.edits, args.ignoreWhitespace);
96
113
  const structured = {
97
114
  ok: true,
98
115
  path: validPath,
99
116
  appliedEdits,
117
+ ...(appliedEdits > 0 ? { linesAdded, linesRemoved } : {}),
100
118
  ...(unmatchedEdits.length > 0 ? { unmatchedEdits } : {}),
101
119
  ...(lineRange ? { lineRange } : {}),
102
120
  };
@@ -132,9 +150,8 @@ export function registerEditFileTool(server, options = {}) {
132
150
  guard: options.isInitialized,
133
151
  progressMessage: (args) => {
134
152
  const name = path.basename(args.path);
135
- const count = args.edits.length;
136
153
  const tag = args.dryRun ? ' [dry run]' : '';
137
- return `🛠 edit: ${name} [${count} ${count === 1 ? 'edit' : 'edits'}]${tag}`;
154
+ return `🛠 edit: ${name}${tag}`;
138
155
  },
139
156
  completionMessage: (args, result) => {
140
157
  const name = path.basename(args.path);
@@ -144,12 +161,12 @@ export function registerEditFileTool(server, options = {}) {
144
161
  if (!sc.ok)
145
162
  return `🛠 edit: ${name} • failed`;
146
163
  const applied = sc.appliedEdits ?? 0;
147
- const unmatched = sc.unmatchedEdits?.length ?? 0;
164
+ if (applied === 0)
165
+ return `🛠 edit: ${name} • no changes`;
166
+ const added = sc.linesAdded ?? 0;
167
+ const removed = sc.linesRemoved ?? 0;
148
168
  const dry = args.dryRun ? 'dry run — ' : '';
149
- if (unmatched > 0) {
150
- return `🛠 edit: ${name} • ${dry}${applied} applied, ${unmatched} unmatched`;
151
- }
152
- return `🛠 edit: ${name} • ${dry}${applied} applied`;
169
+ return `🛠 edit: ${name} • ${dry} +${added} -${removed}`;
153
170
  },
154
171
  });
155
172
  const validatedHandler = withValidatedArgs(EditFileInputSchema, wrappedHandler);
@@ -17,7 +17,7 @@ export const MOVE_FILE_TOOL = {
17
17
  gotchas: [
18
18
  'On POSIX, an existing destination is silently overwritten; on Windows, rename fails with EEXIST if destination exists.',
19
19
  ],
20
- taskSupport: 'optional',
20
+ taskSupport: 'forbidden',
21
21
  };
22
22
  export async function handleMoveFile(args, signal) {
23
23
  const sources = args.sources ?? (args.source ? [args.source] : []);
@@ -158,12 +158,12 @@ export function registerMoveFileTool(server, options = {}) {
158
158
  const src = path.basename(args.source);
159
159
  if (result.isError)
160
160
  return `🛠 mv: ${src} → ${dest} • failed`;
161
- return `🛠 mv: ${src} → ${dest} • moved`;
161
+ return `🛠 mv: ${src} → ${dest}`;
162
162
  }
163
163
  const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
164
164
  if (result.isError)
165
165
  return `🛠 mv: ${count} items → ${dest} • failed`;
166
- return `🛠 mv: ${count} items → ${dest} • moved`;
166
+ return `🛠 mv: ${count} items → ${dest}`;
167
167
  },
168
168
  });
169
169
  const validatedHandler = withValidatedArgs(MoveFileInputSchema, wrappedHandler);
@@ -1,5 +1,5 @@
1
1
  import * as path from 'node:path';
2
- import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, DEFAULT_SEARCH_TIMEOUT_MS, } from '../lib/constants.js';
2
+ import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { readMultipleFiles } from '../lib/file-operations/metadata.js';
5
5
  import { ReadMultipleFilesInputSchema, ReadMultipleFilesOutputSchema, } from '../schemas.js';
@@ -20,35 +20,81 @@ export const READ_MULTIPLE_FILES_TOOL = {
20
20
  ],
21
21
  };
22
22
  function toStructuredReadManyResult(result) {
23
- const structured = {
23
+ return {
24
24
  path: result.path,
25
+ ...(result.content !== undefined ? { content: result.content } : {}),
26
+ ...(result.truncated ? { truncated: result.truncated } : {}),
27
+ ...(result.resourceUri ? { resourceUri: result.resourceUri } : {}),
28
+ ...(result.head !== undefined ? { head: result.head } : {}),
29
+ ...(result.tail !== undefined ? { tail: result.tail } : {}),
30
+ ...(result.startLine !== undefined ? { startLine: result.startLine } : {}),
31
+ ...(result.endLine !== undefined ? { endLine: result.endLine } : {}),
32
+ ...(result.hasMoreLines ? { hasMoreLines: result.hasMoreLines } : {}),
33
+ ...(result.totalLines !== undefined
34
+ ? { totalLines: result.totalLines }
35
+ : {}),
36
+ ...(result.linesRead !== undefined ? { linesRead: result.linesRead } : {}),
37
+ ...(result.truncationReason
38
+ ? { truncationReason: result.truncationReason }
39
+ : {}),
40
+ ...(result.error ? { error: result.error } : {}),
25
41
  };
26
- if (result.content !== undefined)
27
- structured.content = result.content;
28
- if (result.truncated)
29
- structured.truncated = result.truncated;
30
- if (result.resourceUri)
31
- structured.resourceUri = result.resourceUri;
32
- if (result.head !== undefined)
33
- structured.head = result.head;
34
- if (result.tail !== undefined)
35
- structured.tail = result.tail;
36
- if (result.startLine !== undefined)
37
- structured.startLine = result.startLine;
38
- if (result.endLine !== undefined)
39
- structured.endLine = result.endLine;
40
- if (result.hasMoreLines)
41
- structured.hasMoreLines = result.hasMoreLines;
42
- if (result.totalLines !== undefined)
43
- structured.totalLines = result.totalLines;
44
- if (result.linesRead !== undefined)
45
- structured.linesRead = result.linesRead;
46
- if (result.truncationReason) {
47
- structured.truncationReason = result.truncationReason;
42
+ }
43
+ function resolveReadManyTruncationReason(result) {
44
+ if (!result.truncated)
45
+ return undefined;
46
+ if (result.readMode === 'head')
47
+ return 'head';
48
+ if (result.readMode === 'tail')
49
+ return 'tail';
50
+ if (result.readMode === 'range')
51
+ return 'range';
52
+ return undefined;
53
+ }
54
+ function maybeExternalizeReadManyResult(result, resourceStore) {
55
+ const truncationReason = resolveReadManyTruncationReason(result);
56
+ const baseResult = {
57
+ ...result,
58
+ ...(truncationReason ? { truncationReason } : {}),
59
+ };
60
+ if (!result.content) {
61
+ return baseResult;
62
+ }
63
+ const externalized = maybeExternalizeTextContent(resourceStore, result.content, { name: `read:${path.basename(result.path)}`, mimeType: 'text/plain' });
64
+ if (!externalized) {
65
+ return baseResult;
48
66
  }
49
- if (result.error)
50
- structured.error = result.error;
51
- return structured;
67
+ return {
68
+ ...baseResult,
69
+ content: externalized.preview,
70
+ truncated: true,
71
+ resourceUri: externalized.entry.uri,
72
+ truncationReason: 'externalized',
73
+ };
74
+ }
75
+ function buildReadManyResourceLinks(results) {
76
+ return results.flatMap((result) => {
77
+ if (!result.resourceUri)
78
+ return [];
79
+ return [
80
+ buildResourceLink({
81
+ uri: result.resourceUri,
82
+ name: `read:${path.basename(result.path)}`,
83
+ description: 'Full file contents',
84
+ }),
85
+ ];
86
+ });
87
+ }
88
+ function buildReadManyTextResult(results) {
89
+ return results
90
+ .map((result) => {
91
+ const header = `=== ${result.path} ===`;
92
+ if (result.error) {
93
+ return `${header}\nError: ${result.error}`;
94
+ }
95
+ return `${header}\n${result.content ?? ''}`;
96
+ })
97
+ .join('\n\n');
52
98
  }
53
99
  async function handleReadMultipleFiles(args, signal, resourceStore, onReadComplete) {
54
100
  const options = {
@@ -60,48 +106,9 @@ async function handleReadMultipleFiles(args, signal, resourceStore, onReadComple
60
106
  ...(onReadComplete ? { onReadComplete } : {}),
61
107
  };
62
108
  const results = await readMultipleFiles(args.paths, options);
63
- const maxTotalSize = DEFAULT_READ_MANY_MAX_TOTAL_SIZE;
64
- const mappedResults = results.map((result) => {
65
- let baseTruncationReason;
66
- if (result.truncated && result.readMode === 'head') {
67
- baseTruncationReason = 'head';
68
- }
69
- else if (result.truncated && result.readMode === 'tail') {
70
- baseTruncationReason = 'tail';
71
- }
72
- else if (result.truncated && result.readMode === 'range') {
73
- baseTruncationReason = 'range';
74
- }
75
- const baseResult = {
76
- ...result,
77
- maxTotalSize,
78
- ...(baseTruncationReason
79
- ? { truncationReason: baseTruncationReason }
80
- : {}),
81
- };
82
- if (!result.content) {
83
- return baseResult;
84
- }
85
- const externalized = maybeExternalizeTextContent(resourceStore, result.content, { name: `read:${path.basename(result.path)}`, mimeType: 'text/plain' });
86
- if (!externalized) {
87
- return baseResult;
88
- }
89
- return {
90
- ...baseResult,
91
- content: externalized.preview,
92
- truncated: true,
93
- resourceUri: externalized.entry.uri,
94
- truncationReason: 'externalized',
95
- };
96
- });
97
- let succeeded = 0;
98
- let failed = 0;
99
- for (const result of mappedResults) {
100
- if (result.error === undefined)
101
- succeeded += 1;
102
- else
103
- failed += 1;
104
- }
109
+ const mappedResults = results.map((result) => maybeExternalizeReadManyResult(result, resourceStore));
110
+ const succeeded = mappedResults.filter((r) => r.error === undefined).length;
111
+ const failed = mappedResults.length - succeeded;
105
112
  const structured = {
106
113
  ok: true,
107
114
  results: mappedResults.map((result) => toStructuredReadManyResult(result)),
@@ -111,26 +118,7 @@ async function handleReadMultipleFiles(args, signal, resourceStore, onReadComple
111
118
  failed,
112
119
  },
113
120
  };
114
- const resourceLinks = [];
115
- for (const result of mappedResults) {
116
- if (!result.resourceUri)
117
- continue;
118
- resourceLinks.push(buildResourceLink({
119
- uri: result.resourceUri,
120
- name: `read:${path.basename(result.path)}`,
121
- description: 'Full file contents',
122
- }));
123
- }
124
- const text = mappedResults
125
- .map((result) => {
126
- const header = `=== ${result.path} ===`;
127
- if (result.error) {
128
- return `${header}\nError: ${result.error}`;
129
- }
130
- return `${header}\n${result.content ?? ''}`;
131
- })
132
- .join('\n\n');
133
- return buildToolResponse(text, structured, resourceLinks);
121
+ return buildToolResponse(buildReadManyTextResult(mappedResults), structured, buildReadManyResourceLinks(mappedResults));
134
122
  }
135
123
  export function registerReadMultipleFilesTool(server, options = {}) {
136
124
  const handler = (args, extra) => {
@@ -18,7 +18,7 @@ export const READ_FILE_TOOL = {
18
18
  nuances: [
19
19
  'Large content is externalized to `filesystem-mcp://result/{id}` and preview is returned inline.',
20
20
  ],
21
- taskSupport: 'optional',
21
+ taskSupport: 'forbidden',
22
22
  };
23
23
  async function handleReadFile(args, signal, resourceStore) {
24
24
  const options = {
@@ -3,8 +3,11 @@ import type { z } from 'zod';
3
3
  import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema } from '../schemas.js';
4
4
  import { type ToolContract, type ToolRegistrationOptions, type ToolResponse } from './shared.js';
5
5
  export declare const SEARCH_AND_REPLACE_TOOL: ToolContract;
6
- export declare function handleSearchAndReplace(args: z.infer<typeof SearchAndReplaceInputSchema>, signal?: AbortSignal, onProgress?: (progress: {
6
+ type SearchAndReplaceArgs = z.infer<typeof SearchAndReplaceInputSchema>;
7
+ type SearchAndReplaceOutput = z.infer<typeof SearchAndReplaceOutputSchema>;
8
+ export declare function handleSearchAndReplace(args: SearchAndReplaceArgs, signal?: AbortSignal, onProgress?: (progress: {
7
9
  total?: number;
8
10
  current: number;
9
- }) => void): Promise<ToolResponse<z.infer<typeof SearchAndReplaceOutputSchema>>>;
11
+ }) => void): Promise<ToolResponse<SearchAndReplaceOutput>>;
10
12
  export declare function registerSearchAndReplaceTool(server: McpServer, options?: ToolRegistrationOptions): void;
13
+ export {};
@@ -94,6 +94,17 @@ function createLiteralReplacementMatcher(searchPattern, caseSensitive) {
94
94
  const replace = (content, replacement) => content.replaceAll(searchPattern, () => replacement);
95
95
  return { count, replace };
96
96
  }
97
+ function buildReplacementPlan(content, replacement, matcher) {
98
+ const matchCount = matcher.count(content);
99
+ if (matchCount === 0) {
100
+ return undefined;
101
+ }
102
+ return {
103
+ matchCount,
104
+ originalContent: content,
105
+ updatedContent: matcher.replace(content, replacement),
106
+ };
107
+ }
97
108
  function formatFileTooLargeError(filePath, size, maxFileSize) {
98
109
  return `File too large: ${filePath} (${size} bytes > ${maxFileSize} bytes)`;
99
110
  }
@@ -112,37 +123,29 @@ async function processEntry(entryPath, context) {
112
123
  return;
113
124
  }
114
125
  try {
115
- const stats = await withAbort(fs.stat(validPath), signal);
116
- if (stats.size > maxFileSize) {
117
- summary.failedFiles++;
118
- recordFailure(summary.failures, {
119
- path: validPath,
120
- error: formatFileTooLargeError(validPath, stats.size, maxFileSize),
121
- });
126
+ const plan = await readReplacementPlan(validPath, {
127
+ matcher,
128
+ replacement,
129
+ maxFileSize,
130
+ signal,
131
+ });
132
+ if (!plan) {
122
133
  return;
123
134
  }
124
- const content = await fs.readFile(validPath, {
125
- encoding: 'utf-8',
126
- signal,
135
+ summary.totalMatches += plan.matchCount;
136
+ summary.filesChanged++;
137
+ recordChangedFile(summary, validPath, plan.matchCount);
138
+ maybeAppendPatchDiff(summary, {
139
+ filePath: validPath,
140
+ originalContent: plan.originalContent,
141
+ updatedContent: plan.updatedContent,
142
+ includeDiff: options.dryRun || options.returnDiff,
127
143
  });
128
- const matchCount = matcher.count(content);
129
- if (matchCount > 0) {
130
- summary.totalMatches += matchCount;
131
- summary.filesChanged++;
132
- recordChangedFile(summary, validPath, matchCount);
133
- const newContent = matcher.replace(content, replacement);
134
- maybeAppendPatchDiff(summary, {
135
- filePath: validPath,
136
- originalContent: content,
137
- updatedContent: newContent,
138
- includeDiff: options.dryRun || options.returnDiff,
144
+ if (!options.dryRun) {
145
+ await atomicWriteFile(validPath, plan.updatedContent, {
146
+ encoding: 'utf-8',
147
+ signal,
139
148
  });
140
- if (!options.dryRun) {
141
- await atomicWriteFile(validPath, newContent, {
142
- encoding: 'utf-8',
143
- signal,
144
- });
145
- }
146
149
  }
147
150
  }
148
151
  catch (error) {
@@ -153,6 +156,17 @@ async function processEntry(entryPath, context) {
153
156
  });
154
157
  }
155
158
  }
159
+ async function readReplacementPlan(validPath, context) {
160
+ const stats = await withAbort(fs.stat(validPath), context.signal);
161
+ if (stats.size > context.maxFileSize) {
162
+ throw new Error(formatFileTooLargeError(validPath, stats.size, context.maxFileSize));
163
+ }
164
+ const content = await fs.readFile(validPath, {
165
+ encoding: 'utf-8',
166
+ signal: context.signal,
167
+ });
168
+ return buildReplacementPlan(content, context.replacement, context.matcher);
169
+ }
156
170
  function maybeAppendPatchDiff(summary, params) {
157
171
  if (!params.includeDiff)
158
172
  return;
@@ -283,27 +297,7 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
283
297
  throttleModulo: 25,
284
298
  force: true,
285
299
  });
286
- const failureSuffix = summary.failedFiles > 0 ? ` (${summary.failedFiles} failed)` : '';
287
- return buildToolResponse(`Found ${summary.totalMatches} matches in ${summary.filesChanged} files${failureSuffix}.${args.dryRun ? ' (Dry run)' : ''}`, {
288
- ok: true,
289
- matches: summary.totalMatches,
290
- filesChanged: summary.filesChanged,
291
- processedFiles: summary.processedFiles,
292
- ...(summary.failedFiles > 0 ? { failedFiles: summary.failedFiles } : {}),
293
- ...(summary.failures.length > 0 ? { failures: summary.failures } : {}),
294
- ...(summary.changedFiles.length > 0
295
- ? { changedFiles: summary.changedFiles }
296
- : {}),
297
- ...(summary.changedFilesTruncated ? { changedFilesTruncated: true } : {}),
298
- ...((args.dryRun || args.returnDiff) && summary.diff
299
- ? { diff: summary.diff }
300
- : {}),
301
- ...(summary.diffTruncated ? { diffTruncated: true } : {}),
302
- ...(summary.stoppedReason
303
- ? { stoppedReason: summary.stoppedReason }
304
- : {}),
305
- dryRun: args.dryRun,
306
- });
300
+ return buildToolResponse(buildSearchAndReplaceText(summary, args.dryRun), buildSearchAndReplaceStructuredResult(summary, args));
307
301
  }
308
302
  export function registerSearchAndReplaceTool(server, options = {}) {
309
303
  const handler = (args, extra) => executeToolWithDiagnostics({
@@ -350,3 +344,28 @@ export function registerSearchAndReplaceTool(server, options = {}) {
350
344
  return;
351
345
  server.registerTool('search_and_replace', withDefaultIcons({ ...SEARCH_AND_REPLACE_TOOL }, options.iconInfo), validatedHandler);
352
346
  }
347
+ function buildSearchAndReplaceText(summary, dryRun) {
348
+ const failureSuffix = summary.failedFiles > 0 ? ` (${summary.failedFiles} failed)` : '';
349
+ const dryRunSuffix = dryRun ? ' (Dry run)' : '';
350
+ return `Found ${summary.totalMatches} matches in ${summary.filesChanged} files${failureSuffix}.${dryRunSuffix}`;
351
+ }
352
+ function buildSearchAndReplaceStructuredResult(summary, args) {
353
+ return {
354
+ ok: true,
355
+ matches: summary.totalMatches,
356
+ filesChanged: summary.filesChanged,
357
+ processedFiles: summary.processedFiles,
358
+ ...(summary.failedFiles > 0 ? { failedFiles: summary.failedFiles } : {}),
359
+ ...(summary.failures.length > 0 ? { failures: summary.failures } : {}),
360
+ ...(summary.changedFiles.length > 0
361
+ ? { changedFiles: summary.changedFiles }
362
+ : {}),
363
+ ...(summary.changedFilesTruncated ? { changedFilesTruncated: true } : {}),
364
+ ...((args.dryRun || args.returnDiff) && summary.diff
365
+ ? { diff: summary.diff }
366
+ : {}),
367
+ ...(summary.diffTruncated ? { diffTruncated: true } : {}),
368
+ ...(summary.stoppedReason ? { stoppedReason: summary.stoppedReason } : {}),
369
+ dryRun: args.dryRun,
370
+ };
371
+ }
@@ -12,7 +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
+ taskSupport: 'forbidden',
16
16
  };
17
17
  function buildTextRoots(dirs) {
18
18
  if (dirs.length === 0) {
@@ -8,6 +8,11 @@ import { SearchContentInputSchema, SearchContentOutputSchema, } from '../schemas
8
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
+ const SEARCH_COMPLETION_REASON_LABELS = {
12
+ timeout: 'timeout',
13
+ maxResults: 'max results',
14
+ maxFiles: 'max files',
15
+ };
11
16
  export const SEARCH_CONTENT_TOOL = {
12
17
  name: 'grep',
13
18
  title: 'Search Content',
@@ -26,20 +31,11 @@ export const SEARCH_CONTENT_TOOL = {
26
31
  ],
27
32
  taskSupport: 'optional',
28
33
  };
29
- function assertValidRegexPattern(pattern) {
34
+ function findColumnOffset(content, pattern, matcher, caseSensitive) {
30
35
  try {
31
- new RE2(pattern);
32
- }
33
- catch (error) {
34
- throw new McpError(ErrorCode.E_INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
35
- }
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);
36
+ if (matcher) {
37
+ matcher.lastIndex = 0;
38
+ const match = matcher.exec(content);
43
39
  return match ? match.index : undefined;
44
40
  }
45
41
  if (caseSensitive) {
@@ -55,19 +51,21 @@ function findColumnOffset(content, pattern, isRegex, caseSensitive) {
55
51
  return undefined;
56
52
  }
57
53
  }
58
- function buildSearchTextResult(result, normalizedMatches) {
59
- const { summary } = result;
54
+ function buildSearchTextResult(heading, normalizedMatches, summary) {
60
55
  if (normalizedMatches.length === 0)
61
56
  return 'No matches';
62
- let truncatedReason;
63
- if (summary.truncated) {
64
- truncatedReason = resolveTruncatedReason(summary);
57
+ const text = buildMatchListText(heading, normalizedMatches);
58
+ if (!summary) {
59
+ return text;
65
60
  }
61
+ const truncatedReason = summary.truncated
62
+ ? resolveTruncatedReason(summary)
63
+ : undefined;
66
64
  const summaryOptions = {
67
65
  truncated: summary.truncated,
68
66
  ...(truncatedReason ? { truncatedReason } : {}),
69
67
  };
70
- return (buildMatchListText(`Found ${normalizedMatches.length}:`, normalizedMatches) + formatOperationSummary(summaryOptions));
68
+ return text + formatOperationSummary(summaryOptions);
71
69
  }
72
70
  function resolveTruncatedReason(summary) {
73
71
  if (summary.stoppedReason === 'timeout')
@@ -85,7 +83,7 @@ function buildMatchListText(heading, matches) {
85
83
  return joinLines(lines);
86
84
  }
87
85
  function buildSearchMatchPayload(match, context) {
88
- const column = findColumnOffset(match.content, context.pattern, context.isRegex, context.caseSensitive);
86
+ const column = findColumnOffset(match.content, context.pattern, context.matcher, context.caseSensitive);
89
87
  return {
90
88
  file: match.relativeFile,
91
89
  line: match.line,
@@ -100,9 +98,10 @@ function formatSearchMatchLine(match) {
100
98
  const lineNum = String(match.line).padStart(4);
101
99
  return ` ${match.relativeFile}:${lineNum}: ${match.content}`;
102
100
  }
103
- function buildStructuredSearchResult(result, normalizedMatches, options, context) {
104
- const { summary } = result;
105
- const matches = normalizedMatches.map((match) => buildSearchMatchPayload(match, context));
101
+ function buildSearchMatchPayloads(matches, context) {
102
+ return matches.map((match) => buildSearchMatchPayload(match, context));
103
+ }
104
+ function buildStructuredSearchResult(summary, matches, options) {
106
105
  return {
107
106
  ok: true,
108
107
  patternType: options.patternType,
@@ -155,8 +154,15 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
155
154
  const basePath = resolvePathOrRoot(args.path);
156
155
  const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
157
156
  const patternType = args.isRegex ? 'regex' : 'literal';
157
+ let regexMatcher;
158
158
  if (args.isRegex) {
159
- assertValidRegexPattern(args.pattern);
159
+ try {
160
+ const flags = (args.caseSensitive ? '' : 'i') + (args.multiline ? 'm' : '');
161
+ regexMatcher = new RE2(args.pattern, flags);
162
+ }
163
+ catch (error) {
164
+ throw new McpError(ErrorCode.E_INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
165
+ }
160
166
  }
161
167
  const options = {
162
168
  includeHidden: args.includeHidden,
@@ -188,19 +194,20 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
188
194
  const normalizedMatches = normalizeSearchMatches(result);
189
195
  const searchContext = {
190
196
  pattern: args.pattern,
191
- isRegex: args.isRegex,
197
+ matcher: regexMatcher,
192
198
  caseSensitive: args.caseSensitive,
193
199
  };
194
- const structuredFull = buildStructuredSearchResult(result, normalizedMatches, {
200
+ const matchPayloads = buildSearchMatchPayloads(normalizedMatches, searchContext);
201
+ const structuredFull = buildStructuredSearchResult(result.summary, matchPayloads, {
195
202
  patternType,
196
203
  caseSensitive: args.caseSensitive,
197
- }, searchContext);
204
+ });
198
205
  const needsExternalize = normalizedMatches.length > MAX_INLINE_MATCHES;
199
206
  if (!resourceStore || !needsExternalize) {
200
- return buildToolResponse(buildSearchTextResult(result, normalizedMatches), structuredFull);
207
+ return buildToolResponse(buildSearchTextResult(`Found ${normalizedMatches.length}:`, normalizedMatches, result.summary), structuredFull);
201
208
  }
202
209
  const previewMatches = normalizedMatches.slice(0, MAX_INLINE_MATCHES);
203
- const previewPayload = previewMatches.map((match) => buildSearchMatchPayload(match, searchContext));
210
+ const previewPayload = buildSearchMatchPayloads(previewMatches, searchContext);
204
211
  const previewStructured = {
205
212
  ...structuredFull,
206
213
  matches: previewPayload,
@@ -213,7 +220,7 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
213
220
  text: JSON.stringify(structuredFull),
214
221
  });
215
222
  previewStructured.resourceUri = entry.uri;
216
- const text = buildMatchListText(`Found ${normalizedMatches.length} (showing first ${MAX_INLINE_MATCHES}):`, previewMatches);
223
+ const text = buildSearchTextResult(`Found ${normalizedMatches.length} (showing first ${MAX_INLINE_MATCHES}):`, previewMatches);
217
224
  return buildToolResponse(text, previewStructured, [
218
225
  buildResourceLink({
219
226
  uri: entry.uri,
@@ -263,22 +270,6 @@ export function registerSearchContentTool(server, options = {}) {
263
270
  },
264
271
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? '.'),
265
272
  });
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
- }
282
273
  const { isInitialized } = options;
283
274
  const wrappedHandler = wrapToolHandler(handler, {
284
275
  guard: isInitialized,
@@ -288,3 +279,14 @@ export function registerSearchContentTool(server, options = {}) {
288
279
  return;
289
280
  server.registerTool('grep', withDefaultIcons({ ...SEARCH_CONTENT_TOOL }, options.iconInfo), validatedHandler);
290
281
  }
282
+ function buildCompletionSuffix(params) {
283
+ if (params.count === 0) {
284
+ return `No matches in ${params.scope}`;
285
+ }
286
+ const matchWord = params.count === 1 ? 'match' : 'matches';
287
+ const fileWord = params.filesMatched === 1 ? 'file' : 'files';
288
+ const reasonSuffix = params.stoppedReason !== undefined
289
+ ? ` [${SEARCH_COMPLETION_REASON_LABELS[params.stoppedReason]}]`
290
+ : '';
291
+ return `${params.count} ${matchWord} in ${params.filesMatched} ${fileWord}${reasonSuffix}`;
292
+ }