@j0hanz/filesystem-mcp 1.14.0 → 1.14.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.
Files changed (66) hide show
  1. package/README.md +47 -47
  2. package/dist/cli.js +2 -2
  3. package/dist/completions.js +54 -51
  4. package/dist/config.d.ts +13 -13
  5. package/dist/config.js +12 -12
  6. package/dist/index.js +1 -1
  7. package/dist/lib/abort.d.ts +7 -0
  8. package/dist/lib/abort.js +81 -0
  9. package/dist/lib/constants.d.ts +3 -1
  10. package/dist/lib/constants.js +8 -2
  11. package/dist/lib/errors.d.ts +7 -3
  12. package/dist/lib/errors.js +59 -39
  13. package/dist/lib/file-operations/core.d.ts +3 -3
  14. package/dist/lib/file-operations/core.js +23 -20
  15. package/dist/lib/file-operations/metadata.d.ts +2 -2
  16. package/dist/lib/file-operations/metadata.js +60 -19
  17. package/dist/lib/file-operations/search.js +83 -84
  18. package/dist/lib/file-operations/traversal.js +13 -15
  19. package/dist/lib/fs-helpers.d.ts +3 -10
  20. package/dist/lib/fs-helpers.js +20 -98
  21. package/dist/lib/globs.js +1 -1
  22. package/dist/lib/logger.d.ts +28 -0
  23. package/dist/lib/logger.js +91 -0
  24. package/dist/lib/observability.d.ts +7 -0
  25. package/dist/lib/observability.js +19 -9
  26. package/dist/lib/paths.js +55 -55
  27. package/dist/lib/resource-store.js +4 -4
  28. package/dist/lib/utils.d.ts +0 -12
  29. package/dist/lib/utils.js +0 -13
  30. package/dist/resources/generated-instructions.js +40 -31
  31. package/dist/resources/tool-catalog.js +32 -26
  32. package/dist/resources/tool-info.js +34 -29
  33. package/dist/resources/workflows.js +39 -18
  34. package/dist/resources.d.ts +1 -1
  35. package/dist/resources.js +4 -4
  36. package/dist/schemas.d.ts +66 -66
  37. package/dist/schemas.js +21 -44
  38. package/dist/server/bootstrap.d.ts +12 -11
  39. package/dist/server/bootstrap.js +95 -86
  40. package/dist/server/roots-manager.d.ts +5 -2
  41. package/dist/server/roots-manager.js +8 -6
  42. package/dist/server/task-store.d.ts +10 -0
  43. package/dist/server/task-store.js +73 -0
  44. package/dist/tools/apply-patch.js +26 -18
  45. package/dist/tools/calculate-hash.js +11 -22
  46. package/dist/tools/create-directory.js +10 -8
  47. package/dist/tools/delete-file.js +17 -15
  48. package/dist/tools/diff-files.js +15 -15
  49. package/dist/tools/edit-file.js +7 -4
  50. package/dist/tools/list-directory.js +6 -6
  51. package/dist/tools/move-file.js +98 -81
  52. package/dist/tools/read-multiple.js +4 -4
  53. package/dist/tools/read.js +5 -5
  54. package/dist/tools/replace-in-files.js +20 -23
  55. package/dist/tools/roots.js +1 -1
  56. package/dist/tools/search-content.js +32 -41
  57. package/dist/tools/search-files.js +54 -41
  58. package/dist/tools/shared.d.ts +3 -0
  59. package/dist/tools/shared.js +70 -28
  60. package/dist/tools/stat-many.js +11 -7
  61. package/dist/tools/stat.js +4 -4
  62. package/dist/tools/task-support.d.ts +10 -9
  63. package/dist/tools/task-support.js +94 -23
  64. package/dist/tools/tree.js +3 -3
  65. package/dist/tools/write-file.js +10 -7
  66. package/package.json +9 -9
@@ -1,7 +1,8 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
1
+ import { cp, mkdir, rename, rm, stat } from 'node:fs/promises';
2
+ import { basename, dirname, join, resolve, sep } from 'node:path';
3
+ import { withAbort } from '../lib/abort.js';
3
4
  import { ErrorCode, formatUnknownErrorMessage, isNodeError, McpError, } from '../lib/errors.js';
4
- import { withAbort } from '../lib/fs-helpers.js';
5
+ import { Logger } from '../lib/logger.js';
5
6
  import { assertAllowedFileAccess, validateExistingPath, validatePathForWrite, } from '../lib/paths.js';
6
7
  import { MoveFileInputSchema, MoveFileOutputSchema } from '../schemas.js';
7
8
  import { buildStructuredError, buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -18,103 +19,119 @@ export const MOVE_FILE_TOOL = {
18
19
  ],
19
20
  taskSupport: 'forbidden',
20
21
  };
21
- function toMoveFailure(source, error, defaultCode = ErrorCode.E_UNKNOWN) {
22
+ function toMoveFailure(source, error, defaultCode = ErrorCode.UNKNOWN) {
22
23
  return {
23
24
  source,
24
25
  error: buildStructuredError(error, defaultCode, source),
25
26
  };
26
27
  }
27
- async function handleMoveFile(args, signal) {
28
- const sources = args.sources ?? (args.source ? [args.source] : []);
29
- if (sources.length === 0) {
30
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'No sources provided.');
28
+ async function handleMoveError(error, src, validSource, targetPath, movedSources, failed, signal) {
29
+ if (isNodeError(error) && error.code === 'EXDEV') {
30
+ try {
31
+ await withAbort(cp(validSource, targetPath, { recursive: true }), signal);
32
+ }
33
+ catch (copyError) {
34
+ failed.push(toMoveFailure(src, copyError));
35
+ return;
36
+ }
37
+ try {
38
+ await withAbort(rm(validSource, { recursive: true, force: true }), signal);
39
+ movedSources.push(validSource);
40
+ }
41
+ catch (deleteError) {
42
+ try {
43
+ await rm(targetPath, { recursive: true, force: true });
44
+ }
45
+ catch {
46
+ failed.push(toMoveFailure(src, new McpError(ErrorCode.UNKNOWN, `Cross-device move partial: data at both source and destination. ${formatUnknownErrorMessage(deleteError)}`, src)));
47
+ return;
48
+ }
49
+ failed.push(toMoveFailure(src, new McpError(ErrorCode.UNKNOWN, `Cross-device move failed: source not removed. ${formatUnknownErrorMessage(deleteError)}`, src)));
50
+ }
51
+ }
52
+ else {
53
+ failed.push(toMoveFailure(src, error));
54
+ }
55
+ }
56
+ async function processSingleMove(src, destIsDirectory, validDest, movedSources, failed, signal) {
57
+ let validSource;
58
+ try {
59
+ validSource = await validateExistingPath(src, signal);
60
+ assertAllowedFileAccess(src, validSource);
61
+ }
62
+ catch (error) {
63
+ failed.push(toMoveFailure(src, error, ErrorCode.ACCESS_DENIED));
64
+ return;
65
+ }
66
+ const targetPath = destIsDirectory
67
+ ? join(validDest, basename(validSource))
68
+ : validDest;
69
+ if (resolve(validSource) === resolve(targetPath)) {
70
+ return;
71
+ }
72
+ if (resolve(targetPath).startsWith(resolve(validSource) + sep)) {
73
+ failed.push(toMoveFailure(src, new McpError(ErrorCode.INVALID_INPUT, 'Cannot move a directory into its own subdirectory', src), ErrorCode.INVALID_INPUT));
74
+ return;
31
75
  }
32
- const validDest = await validatePathForWrite(args.destination, signal);
33
- // Check if destination exists and is a directory
34
- let destIsDirectory = false;
35
76
  try {
36
- const stats = await fs.stat(validDest);
37
- destIsDirectory = stats.isDirectory();
77
+ await withAbort(rename(validSource, targetPath), signal);
78
+ movedSources.push(validSource);
79
+ }
80
+ catch (error) {
81
+ await handleMoveError(error, src, validSource, targetPath, movedSources, failed, signal);
82
+ }
83
+ }
84
+ async function getDestinationStatus(validDest) {
85
+ try {
86
+ const stats = await stat(validDest);
87
+ return stats.isDirectory();
38
88
  }
39
89
  catch (error) {
40
90
  if (isNodeError(error) && error.code !== 'ENOENT') {
41
91
  throw error;
42
92
  }
43
93
  }
94
+ return false;
95
+ }
96
+ function formatMoveMessage(moved, failed, destination) {
97
+ const movedItemStr = `item${moved === 1 ? '' : 's'}`;
98
+ const failedItemStr = `item${failed === 1 ? '' : 's'}`;
99
+ if (failed > 0) {
100
+ return `Moved ${moved} ${movedItemStr}; failed to move ${failed} ${failedItemStr}`;
101
+ }
102
+ return `Successfully moved ${moved} ${movedItemStr} to ${destination}`;
103
+ }
104
+ async function handleMoveFile(args, signal) {
105
+ const sources = args.sources ?? (args.source ? [args.source] : []);
106
+ if (sources.length === 0) {
107
+ throw new McpError(ErrorCode.INVALID_INPUT, 'No sources provided.');
108
+ }
109
+ const validDest = await validatePathForWrite(args.destination, signal);
110
+ const destIsDirectory = await getDestinationStatus(validDest);
44
111
  if (sources.length > 1 && !destIsDirectory) {
45
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Destination must be an existing directory when moving multiple files.');
112
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Destination must be an existing directory for multiple sources.');
46
113
  }
47
- // Ensure destination parent directory exists if it's not an existing directory
48
114
  if (!destIsDirectory) {
49
- await withAbort(fs.mkdir(path.dirname(validDest), { recursive: true }), signal);
115
+ await withAbort(mkdir(dirname(validDest), { recursive: true }), signal);
50
116
  }
51
117
  const movedSources = [];
52
118
  const failed = [];
53
119
  for (const src of sources) {
54
- let validSource;
55
- try {
56
- validSource = await validateExistingPath(src, signal);
57
- assertAllowedFileAccess(src, validSource);
58
- }
59
- catch (error) {
60
- failed.push(toMoveFailure(src, error, ErrorCode.E_ACCESS_DENIED));
61
- continue;
62
- }
63
- const targetPath = destIsDirectory
64
- ? path.join(validDest, path.basename(validSource))
65
- : validDest;
66
- // Prevent moving a file onto itself
67
- if (path.resolve(validSource) === path.resolve(targetPath)) {
68
- continue;
69
- }
70
- // Prevent moving a directory into its own subdirectory
71
- // Fixes "Missing validation for moving directory into its own subdirectory" finding
72
- if (path.resolve(targetPath).startsWith(path.resolve(validSource) + path.sep)) {
73
- failed.push(toMoveFailure(src, new McpError(ErrorCode.E_INVALID_INPUT, `Cannot move directory '${src}' into its own subdirectory '${targetPath}'`, src), ErrorCode.E_INVALID_INPUT));
74
- continue;
75
- }
76
- try {
77
- await withAbort(fs.rename(validSource, targetPath), signal);
78
- movedSources.push(validSource);
79
- }
80
- catch (error) {
81
- if (isNodeError(error) && error.code === 'EXDEV') {
82
- // Cross-device link, fallback to copy + delete
83
- try {
84
- await withAbort(fs.cp(validSource, targetPath, { recursive: true }), signal);
85
- }
86
- catch (copyError) {
87
- failed.push(toMoveFailure(src, copyError));
88
- continue;
89
- }
90
- // Copy succeeded — now remove source
91
- try {
92
- await withAbort(fs.rm(validSource, { recursive: true, force: true }), signal);
93
- movedSources.push(validSource);
94
- }
95
- catch (deleteError) {
96
- // Source delete failed after copy — rollback by removing the copy
97
- try {
98
- await fs.rm(targetPath, { recursive: true, force: true });
99
- }
100
- catch {
101
- // Rollback failed — data exists in both locations
102
- failed.push(toMoveFailure(src, new McpError(ErrorCode.E_UNKNOWN, `Cross-device move partially failed: data exists at both '${validSource}' and '${targetPath}'. ${formatUnknownErrorMessage(deleteError)}`, src)));
103
- continue;
104
- }
105
- failed.push(toMoveFailure(src, new McpError(ErrorCode.E_UNKNOWN, `Cross-device move failed: could not remove source. ${formatUnknownErrorMessage(deleteError)}`, src)));
106
- }
107
- }
108
- else {
109
- failed.push(toMoveFailure(src, error));
110
- }
120
+ await processSingleMove(src, destIsDirectory, validDest, movedSources, failed, signal);
121
+ }
122
+ const message = formatMoveMessage(movedSources.length, failed.length, args.destination);
123
+ const movedSource = sources.length === 1 ? movedSources[0] : undefined;
124
+ const failedSuffix = failed.length > 0 ? ` (${failed.length} failed)` : '';
125
+ Logger.info(`mv: ${movedSources.length} item(s) → ${args.destination}${failedSuffix}`);
126
+ if (movedSources.length === 0 && failed.length > 0) {
127
+ const firstFailure = failed[0];
128
+ if (firstFailure) {
129
+ throw new McpError(firstFailure.error.code, message, firstFailure.error.path);
111
130
  }
112
131
  }
113
- const message = failed.length > 0
114
- ? `Moved ${movedSources.length} item${movedSources.length === 1 ? '' : 's'}; failed to move ${failed.length} item${failed.length === 1 ? '' : 's'}`
115
- : `Successfully moved ${movedSources.length} item${movedSources.length === 1 ? '' : 's'} to ${args.destination}`;
116
132
  return buildToolResponse(message, {
117
133
  ok: failed.length === 0,
134
+ ...(movedSource ? { source: movedSource } : {}),
118
135
  sources: movedSources,
119
136
  destination: validDest,
120
137
  ...(failed.length > 0 ? { failed } : {}),
@@ -128,22 +145,22 @@ export function registerMoveFileTool(server, options = {}) {
128
145
  timedSignal: {},
129
146
  context: { path: args.source ?? args.sources?.[0] },
130
147
  run: (signal) => handleMoveFile(args, signal),
131
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.source ?? args.sources?.[0]),
148
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.source ?? args.sources?.[0]),
132
149
  });
133
150
  const wrappedHandler = wrapToolHandler(handler, {
134
151
  guard: options.isInitialized,
135
152
  progressMessage: (args) => {
136
- const dest = path.basename(args.destination);
153
+ const dest = basename(args.destination);
137
154
  if (args.source && !args.sources?.length) {
138
- return `🛠 mv: ${path.basename(args.source)} → ${dest}`;
155
+ return `🛠 mv: ${basename(args.source)} → ${dest}`;
139
156
  }
140
157
  const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
141
158
  return `🛠 mv: ${count} items → ${dest}`;
142
159
  },
143
160
  completionMessage: (args, result) => {
144
- const dest = path.basename(args.destination);
161
+ const dest = basename(args.destination);
145
162
  if (args.source && !args.sources?.length) {
146
- const src = path.basename(args.source);
163
+ const src = basename(args.source);
147
164
  if (result.isError)
148
165
  return `🛠 mv: ${src} → ${dest} • failed`;
149
166
  return `🛠 mv: ${src} → ${dest}`;
@@ -1,4 +1,4 @@
1
- import * as path from 'node:path';
1
+ import { basename } from 'node:path';
2
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';
@@ -19,7 +19,7 @@ export const READ_MANY_TOOL = {
19
19
  taskSupport: 'optional',
20
20
  };
21
21
  function buildReadManyResourceName(filePath) {
22
- return `read:${path.basename(filePath)}`;
22
+ return `read:${basename(filePath)}`;
23
23
  }
24
24
  function toStructuredReadManyResult(result) {
25
25
  const structuredResult = {
@@ -72,7 +72,7 @@ function maybeExternalizeReadManyResult(result, resourceStore) {
72
72
  const baseResult = {
73
73
  ...rest,
74
74
  ...(error
75
- ? { error: buildStructuredError(error, ErrorCode.E_UNKNOWN, result.path) }
75
+ ? { error: buildStructuredError(error, ErrorCode.UNKNOWN, result.path) }
76
76
  : {}),
77
77
  ...(truncationReason ? { truncationReason } : {}),
78
78
  };
@@ -191,7 +191,7 @@ export function registerReadMultipleFilesTool(server, options = {}) {
191
191
  throw error;
192
192
  }
193
193
  },
194
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, primaryPath),
194
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.NOT_FILE, primaryPath),
195
195
  });
196
196
  };
197
197
  const wrappedHandler = wrapToolHandler(handler, {
@@ -1,5 +1,5 @@
1
- import * as path from 'node:path';
2
1
  import { createHash } from 'node:crypto';
2
+ import { basename } from 'node:path';
3
3
  import { DEFAULT_SEARCH_TIMEOUT_MS, MAX_TEXT_FILE_SIZE, } from '../lib/constants.js';
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { readFile } from '../lib/fs-helpers.js';
@@ -24,7 +24,7 @@ const READ_TOOL_NAME = 'read';
24
24
  const READ_TOOL_LABEL = '🕮 read';
25
25
  const FULL_FILE_CONTENTS_DESCRIPTION = 'Full file contents';
26
26
  function buildReadResourceName(filePath) {
27
- return `read:${path.basename(filePath)}`;
27
+ return `read:${basename(filePath)}`;
28
28
  }
29
29
  function buildReadOptions(args, signal) {
30
30
  const options = {
@@ -99,7 +99,7 @@ function maybeBuildExternalizedReadResponse(filePath, content, structured, resou
99
99
  ]);
100
100
  }
101
101
  function buildReadProgressMessage(args) {
102
- const name = path.basename(args.path);
102
+ const name = basename(args.path);
103
103
  if (args.startLine !== undefined) {
104
104
  const end = args.endLine ?? '…';
105
105
  return `${READ_TOOL_LABEL}: ${name} [lines ${args.startLine}–${end}]`;
@@ -111,7 +111,7 @@ function buildReadProgressMessage(args) {
111
111
  return `${READ_TOOL_LABEL}: ${name}`;
112
112
  }
113
113
  function buildReadCompletionMessage(args, result) {
114
- const name = path.basename(args.path);
114
+ const name = basename(args.path);
115
115
  if (result.isError)
116
116
  return `${READ_TOOL_LABEL}: ${name} • failed`;
117
117
  const structured = result.structuredContent;
@@ -160,7 +160,7 @@ export function registerReadFileTool(server, options = {}) {
160
160
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
161
161
  context: { path: args.path },
162
162
  run: (signal) => handleReadFile(args, signal, options.resourceStore),
163
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, args.path),
163
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.NOT_FILE, args.path),
164
164
  });
165
165
  const wrappedHandler = wrapToolHandler(handler, {
166
166
  guard: options.isInitialized,
@@ -1,15 +1,14 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
3
1
  import { Buffer } from 'node:buffer';
2
+ import { open } from 'node:fs/promises';
3
+ import { basename, relative } from 'node:path';
4
4
  import { createTwoFilesPatch } from 'diff';
5
5
  import RE2 from 're2';
6
- import safeRegex from 'safe-regex2';
7
6
  import { DEFAULT_EXCLUDE_PATTERNS, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from '../lib/constants.js';
8
7
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
9
8
  import { globEntries } from '../lib/file-operations/traversal.js';
10
9
  import { atomicWriteFile } from '../lib/fs-helpers.js';
10
+ import { Logger } from '../lib/logger.js';
11
11
  import { validateExistingPath, validatePathForWrite } from '../lib/paths.js';
12
- import { reportPeriodicProgress } from '../lib/utils.js';
13
12
  import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
14
13
  import { buildStructuredError, buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolveFinalProgressCurrent, resolvePathOrRoot, truncateProgressPattern, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
15
14
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -36,7 +35,7 @@ function recordFailure(failures, failure) {
36
35
  failures.push(failure);
37
36
  }
38
37
  function recordChangedFile(summary, filePath, matchCount) {
39
- const relativePath = path.relative(summary.root, filePath);
38
+ const relativePath = relative(summary.root, filePath);
40
39
  if (summary.changedFiles.length < MAX_CHANGED_FILES) {
41
40
  summary.changedFiles.push({ path: relativePath, matches: matchCount });
42
41
  return;
@@ -49,11 +48,14 @@ function createRegexMatcher(pattern, caseSensitive) {
49
48
  return new RE2(pattern, flags);
50
49
  }
51
50
  catch (error) {
52
- throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
51
+ throw new McpError(ErrorCode.INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)} (RE2: no lookahead/lookbehind/backrefs)`);
53
52
  }
54
53
  }
55
54
  function createRegexReplacementMatcher(regex) {
56
55
  return {
56
+ testBuffer(buffer) {
57
+ return regex.test(buffer);
58
+ },
57
59
  count(content) {
58
60
  regex.lastIndex = 0;
59
61
  let matchCount = 0;
@@ -120,7 +122,7 @@ async function processEntry(entryPath, ctx) {
120
122
  summary.failedFiles++;
121
123
  recordFailure(summary.failures, {
122
124
  path: entryPath,
123
- error: buildStructuredError(error, ErrorCode.E_UNKNOWN, entryPath),
125
+ error: buildStructuredError(error, ErrorCode.UNKNOWN, entryPath),
124
126
  });
125
127
  return;
126
128
  }
@@ -149,7 +151,7 @@ async function processEntry(entryPath, ctx) {
149
151
  summary.failedFiles++;
150
152
  recordFailure(summary.failures, {
151
153
  path: validPath,
152
- error: buildStructuredError(error, ErrorCode.E_UNKNOWN, validPath),
154
+ error: buildStructuredError(error, ErrorCode.UNKNOWN, validPath),
153
155
  });
154
156
  }
155
157
  }
@@ -157,7 +159,7 @@ async function readReplacementPlan(validPath, ctx) {
157
159
  const { matcher, replacement, maxFileSize, signal } = ctx;
158
160
  let fileHandle;
159
161
  try {
160
- const fd = await fs.open(validPath, 'r');
162
+ const fd = await open(validPath, 'r');
161
163
  fileHandle = fd;
162
164
  const stats = await fileHandle.stat();
163
165
  if (stats.size > maxFileSize) {
@@ -195,7 +197,7 @@ async function maybeAppendPatchDiff(summary, params) {
195
197
  const patch = await new Promise((resolve) => {
196
198
  // Defer to event loop to avoid blocking on large diffs
197
199
  setImmediate(() => {
198
- createTwoFilesPatch(path.basename(params.filePath), path.basename(params.filePath), params.originalContent, params.updatedContent, 'Original', 'Modified', {
200
+ createTwoFilesPatch(basename(params.filePath), basename(params.filePath), params.originalContent, params.updatedContent, 'Original', 'Modified', {
199
201
  callback: (res) => {
200
202
  resolve(res ?? '');
201
203
  },
@@ -262,9 +264,6 @@ async function resolveSearchRoot(pathValue, signal) {
262
264
  function createReplacementRegex(args) {
263
265
  if (!args.isRegex)
264
266
  return undefined;
265
- if (!safeRegex(args.searchPattern)) {
266
- throw new McpError(ErrorCode.E_INVALID_INPUT, `Unsafe regex pattern: ${args.searchPattern}`);
267
- }
268
267
  return createRegexMatcher(args.searchPattern, args.caseSensitive);
269
268
  }
270
269
  function createReplacementMatcher(args) {
@@ -287,7 +286,7 @@ async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
287
286
  cwd: root,
288
287
  pattern: args.filePattern,
289
288
  excludePatterns: args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS,
290
- includeHidden: args.includeHidden ?? false,
289
+ includeHidden: args.includeHidden,
291
290
  baseNameMatch: false,
292
291
  caseSensitiveMatch: true, // Default to sensitive for file paths
293
292
  followSymbolicLinks: false,
@@ -300,7 +299,7 @@ async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
300
299
  const context = {
301
300
  options: {
302
301
  dryRun: args.dryRun,
303
- returnDiff: args.returnDiff ?? false,
302
+ returnDiff: args.returnDiff,
304
303
  },
305
304
  replacement: args.replacement,
306
305
  matcher,
@@ -314,9 +313,7 @@ async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
314
313
  ...(args.maxFiles !== undefined ? { maxEntries: args.maxFiles } : {}),
315
314
  onEntry: () => {
316
315
  summary.processedFiles++;
317
- reportPeriodicProgress(onProgress, summary.processedFiles, {
318
- throttleModulo: 25,
319
- });
316
+ onProgress({ current: summary.processedFiles });
320
317
  },
321
318
  runEntry: (entryPath) => processEntry(entryPath, context),
322
319
  });
@@ -324,10 +321,10 @@ async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
324
321
  if (stoppedByLimit) {
325
322
  summary.stoppedReason = 'maxFiles';
326
323
  }
327
- reportPeriodicProgress(onProgress, summary.processedFiles, {
328
- throttleModulo: 25,
329
- force: true,
330
- });
324
+ onProgress({ current: summary.processedFiles });
325
+ if (!args.dryRun && summary.totalMatches > 0) {
326
+ Logger.info(`search_and_replace: ${summary.filesChanged} file(s), ${summary.totalMatches} match(es)`);
327
+ }
331
328
  return buildToolResponse(buildSearchAndReplaceText(summary, args.dryRun), buildSearchAndReplaceStructuredResult(summary, args));
332
329
  }
333
330
  export function registerSearchAndReplaceTool(server, options = {}) {
@@ -366,7 +363,7 @@ export function registerSearchAndReplaceTool(server, options = {}) {
366
363
  throw error;
367
364
  }
368
365
  },
369
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
366
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
370
367
  });
371
368
  const { isInitialized } = options;
372
369
  const wrappedHandler = wrapToolHandler(handler, {
@@ -36,7 +36,7 @@ export function registerListAllowedDirectoriesTool(server, options = {}) {
36
36
  extra,
37
37
  outputSchema: ListAllowedDirectoriesOutputSchema,
38
38
  run: () => handleListAllowedDirectories(),
39
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN),
39
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN),
40
40
  });
41
41
  const wrappedHandler = wrapToolHandler(handler, {
42
42
  guard: options.isInitialized,
@@ -1,8 +1,8 @@
1
- import * as path from 'node:path';
1
+ import { relative } from 'node:path';
2
2
  import RE2 from 're2';
3
3
  import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
4
4
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
5
- import { searchContent } from '../lib/file-operations/search.js';
5
+ import { searchContent, } from '../lib/file-operations/search.js';
6
6
  import { formatOperationSummary } from '../config.js';
7
7
  import { SearchContentInputSchema, SearchContentOutputSchema, } from '../schemas.js';
8
8
  import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, resolvePathOrRoot, truncateProgressPattern, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -50,25 +50,15 @@ function buildCompletionSuffix(count, filesMatched, scope, stoppedReason) {
50
50
  : '';
51
51
  return `${count} ${matchWord} in ${filesMatched} ${fileWord}${reasonSuffix}`;
52
52
  }
53
- function compareNormalizedMatches(left, right) {
54
- const fileCompare = left.relativeFile.localeCompare(right.relativeFile);
55
- if (fileCompare !== 0)
56
- return fileCompare;
57
- if (left.line !== right.line)
58
- return left.line - right.line;
59
- return left.index - right.index;
60
- }
61
- function buildSearchPreviewState(matches, payloads) {
62
- const needsExternalize = matches.length > CONFIG.MAX_INLINE_MATCHES;
53
+ function buildSearchPreviewState(payloads) {
54
+ const needsExternalize = payloads.length > CONFIG.MAX_INLINE_MATCHES;
63
55
  const visibleCount = needsExternalize
64
56
  ? CONFIG.MAX_INLINE_MATCHES
65
- : matches.length;
66
- const visibleMatches = matches.slice(0, visibleCount);
57
+ : payloads.length;
67
58
  return {
68
59
  needsExternalize,
69
- visibleMatches,
70
60
  visiblePayloads: payloads.slice(0, visibleCount),
71
- heading: buildHeading(matches.length, visibleMatches.length),
61
+ heading: buildHeading(payloads.length, visibleCount),
72
62
  };
73
63
  }
74
64
  export const SEARCH_CONTENT_TOOL = {
@@ -117,29 +107,21 @@ function buildSearchStructured(summary, matches) {
117
107
  ...buildStructuredSummaryFields(summary),
118
108
  };
119
109
  }
120
- function normalizeMatches(result) {
110
+ function buildSortedPayloads(result, context) {
121
111
  const relativeByFile = new Map();
122
112
  const getRelativeFile = (file) => {
123
113
  const cached = relativeByFile.get(file);
124
114
  if (cached !== undefined)
125
115
  return cached;
126
- const relative = path.relative(result.basePath, file);
127
- relativeByFile.set(file, relative);
128
- return relative;
116
+ const rel = relative(result.basePath, file);
117
+ relativeByFile.set(file, rel);
118
+ return rel;
129
119
  };
130
- return result.matches
131
- .map((match, index) => ({
132
- ...match,
133
- relativeFile: getRelativeFile(match.file),
134
- index,
135
- }))
136
- .sort(compareNormalizedMatches);
137
- }
138
- function buildMatchPayloads(matches, context) {
139
- return matches.map((match) => {
120
+ const payloads = result.matches.map((match, originalIndex) => {
121
+ const relFile = getRelativeFile(match.file);
140
122
  const column = findColumnOffset(match.content, context);
141
- return {
142
- file: match.relativeFile,
123
+ const payload = {
124
+ file: relFile,
143
125
  line: match.line,
144
126
  ...(column !== undefined ? { column } : {}),
145
127
  content: match.content,
@@ -149,14 +131,24 @@ function buildMatchPayloads(matches, context) {
149
131
  : {}),
150
132
  ...(match.contextAfter ? { contextAfter: [...match.contextAfter] } : {}),
151
133
  };
134
+ return { payload, originalIndex };
135
+ });
136
+ payloads.sort((left, right) => {
137
+ const fileCompare = left.payload.file.localeCompare(right.payload.file);
138
+ if (fileCompare !== 0)
139
+ return fileCompare;
140
+ if (left.payload.line !== right.payload.line)
141
+ return left.payload.line - right.payload.line;
142
+ return left.originalIndex - right.originalIndex;
152
143
  });
144
+ return payloads.map((p) => p.payload);
153
145
  }
154
146
  function buildMatchList(heading, matches) {
155
147
  if (matches.length === 0)
156
148
  return heading;
157
149
  const parts = [heading];
158
150
  for (const match of matches) {
159
- parts.push(`\n ${match.relativeFile}:${String(match.line).padStart(4)}: ${match.content}`);
151
+ parts.push(`\n ${match.file}:${String(match.line).padStart(4)}: ${match.content}`);
160
152
  }
161
153
  return parts.join('');
162
154
  }
@@ -206,7 +198,7 @@ async function executeSearch(args, basePath, signal, onProgress) {
206
198
  }
207
199
  catch (error) {
208
200
  if (error instanceof Error && /regular expression/i.test(error.message)) {
209
- throw new McpError(ErrorCode.E_INVALID_PATTERN, error.message);
201
+ throw new McpError(ErrorCode.INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)} (RE2: no lookahead/lookbehind/backrefs)`);
210
202
  }
211
203
  throw error;
212
204
  }
@@ -219,7 +211,7 @@ function createSearchMatcher(args) {
219
211
  return new RE2(args.pattern, flags);
220
212
  }
221
213
  catch (error) {
222
- throw new McpError(ErrorCode.E_INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
214
+ throw new McpError(ErrorCode.INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)} (RE2: no lookahead/lookbehind/backrefs)`);
223
215
  }
224
216
  }
225
217
  function createSearchContext(args, matcher) {
@@ -236,11 +228,10 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
236
228
  const basePath = resolvePathOrRoot(args.path);
237
229
  const regexMatcher = createSearchMatcher(args);
238
230
  const result = await executeSearch(args, basePath, signal, onProgress);
239
- const normalizedMatches = normalizeMatches(result);
240
231
  const searchContext = createSearchContext(args, regexMatcher);
241
- const matchPayloads = buildMatchPayloads(normalizedMatches, searchContext);
232
+ const matchPayloads = buildSortedPayloads(result, searchContext);
242
233
  const fullStructured = buildSearchStructured(result.summary, matchPayloads);
243
- const preview = buildSearchPreviewState(normalizedMatches, matchPayloads);
234
+ const preview = buildSearchPreviewState(matchPayloads);
244
235
  if (resourceStore && preview.needsExternalize) {
245
236
  const previewStructured = {
246
237
  ...fullStructured,
@@ -253,7 +244,7 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
253
244
  text: JSON.stringify(fullStructured),
254
245
  });
255
246
  previewStructured.resourceUri = entry.uri;
256
- const text = buildSearchText(preview.heading, preview.visibleMatches);
247
+ const text = buildSearchText(preview.heading, preview.visiblePayloads);
257
248
  return buildToolResponse(text, previewStructured, [
258
249
  buildResourceLink({
259
250
  uri: entry.uri,
@@ -264,7 +255,7 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
264
255
  }),
265
256
  ]);
266
257
  }
267
- const text = buildSearchText(preview.heading, preview.visibleMatches, result.summary);
258
+ const text = buildSearchText(preview.heading, preview.visiblePayloads, result.summary);
268
259
  return buildToolResponse(text, fullStructured);
269
260
  }
270
261
  export function registerSearchContentTool(server, options = {}) {
@@ -298,7 +289,7 @@ export function registerSearchContentTool(server, options = {}) {
298
289
  throw error;
299
290
  }
300
291
  },
301
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? '.'),
292
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path ?? '.'),
302
293
  });
303
294
  const { isInitialized } = options;
304
295
  const wrappedHandler = wrapToolHandler(handler, {