@j0hanz/filesystem-mcp 1.14.0 → 1.15.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -71
  3. package/dist/cli.js +2 -2
  4. package/dist/completions.js +54 -51
  5. package/dist/config.d.ts +13 -13
  6. package/dist/config.js +12 -12
  7. package/dist/index.js +1 -1
  8. package/dist/lib/abort.d.ts +7 -0
  9. package/dist/lib/abort.js +81 -0
  10. package/dist/lib/constants.d.ts +3 -1
  11. package/dist/lib/constants.js +8 -2
  12. package/dist/lib/errors.d.ts +7 -3
  13. package/dist/lib/errors.js +59 -39
  14. package/dist/lib/file-operations/core.d.ts +3 -3
  15. package/dist/lib/file-operations/core.js +23 -20
  16. package/dist/lib/file-operations/metadata.d.ts +2 -2
  17. package/dist/lib/file-operations/metadata.js +60 -19
  18. package/dist/lib/file-operations/search.js +83 -84
  19. package/dist/lib/file-operations/traversal.js +13 -15
  20. package/dist/lib/fs-helpers.d.ts +3 -10
  21. package/dist/lib/fs-helpers.js +20 -98
  22. package/dist/lib/globs.js +1 -1
  23. package/dist/lib/logger.d.ts +28 -0
  24. package/dist/lib/logger.js +91 -0
  25. package/dist/lib/observability.d.ts +7 -0
  26. package/dist/lib/observability.js +19 -9
  27. package/dist/lib/paths.js +55 -55
  28. package/dist/lib/resource-store.js +4 -4
  29. package/dist/lib/utils.d.ts +0 -12
  30. package/dist/lib/utils.js +0 -13
  31. package/dist/resources/generated-instructions.js +40 -31
  32. package/dist/resources/tool-catalog.js +32 -26
  33. package/dist/resources/tool-info.js +34 -29
  34. package/dist/resources/workflows.js +39 -18
  35. package/dist/resources.d.ts +1 -1
  36. package/dist/resources.js +4 -4
  37. package/dist/schemas.d.ts +66 -66
  38. package/dist/schemas.js +21 -44
  39. package/dist/server/bootstrap.d.ts +12 -11
  40. package/dist/server/bootstrap.js +95 -86
  41. package/dist/server/roots-manager.d.ts +5 -2
  42. package/dist/server/roots-manager.js +8 -6
  43. package/dist/server/task-store.d.ts +10 -0
  44. package/dist/server/task-store.js +73 -0
  45. package/dist/tools/apply-patch.js +26 -18
  46. package/dist/tools/calculate-hash.js +11 -22
  47. package/dist/tools/create-directory.js +10 -8
  48. package/dist/tools/delete-file.js +17 -15
  49. package/dist/tools/diff-files.js +15 -15
  50. package/dist/tools/edit-file.js +7 -4
  51. package/dist/tools/list-directory.js +6 -6
  52. package/dist/tools/move-file.js +98 -81
  53. package/dist/tools/read-multiple.js +4 -4
  54. package/dist/tools/read.js +5 -5
  55. package/dist/tools/replace-in-files.js +20 -23
  56. package/dist/tools/roots.js +1 -1
  57. package/dist/tools/search-content.js +32 -41
  58. package/dist/tools/search-files.js +54 -41
  59. package/dist/tools/shared.d.ts +3 -0
  60. package/dist/tools/shared.js +70 -28
  61. package/dist/tools/stat-many.js +11 -7
  62. package/dist/tools/stat.js +4 -4
  63. package/dist/tools/task-support.d.ts +10 -9
  64. package/dist/tools/task-support.js +94 -23
  65. package/dist/tools/tree.js +3 -3
  66. package/dist/tools/write-file.js +10 -7
  67. package/package.json +9 -9
@@ -1,15 +1,14 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
3
1
  import { createHash } from 'node:crypto';
4
2
  import { createReadStream } from 'node:fs';
3
+ import { stat } from 'node:fs/promises';
4
+ import { basename, relative, win32 } from 'node:path';
5
5
  import { pipeline } from 'node:stream/promises';
6
+ import { assertNotAborted, withAbort } from '../lib/abort.js';
6
7
  import { PARALLEL_CONCURRENCY } from '../lib/constants.js';
7
8
  import { ErrorCode } from '../lib/errors.js';
8
9
  import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/core.js';
9
10
  import { globEntries } from '../lib/file-operations/traversal.js';
10
- import { assertNotAborted, withAbort } from '../lib/fs-helpers.js';
11
11
  import { validateExistingPath } from '../lib/paths.js';
12
- import { reportPeriodicProgress } from '../lib/utils.js';
13
12
  import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
14
13
  import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
15
14
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -32,8 +31,8 @@ async function hashFile(filePath, encoding, signal) {
32
31
  return encoding ? hasher.digest(encoding) : hasher.digest();
33
32
  }
34
33
  function toStableRelativePath(root, entryPath) {
35
- const relativePath = path.relative(root, entryPath);
36
- return relativePath.includes(path.win32.sep)
34
+ const relativePath = relative(root, entryPath);
35
+ return relativePath.includes(win32.sep)
37
36
  ? relativePath.replace(WINDOWS_PATH_SEPARATOR, '/')
38
37
  : relativePath;
39
38
  }
@@ -93,18 +92,11 @@ async function hashDirectory(dirPath, options = {}) {
93
92
  const fileHash = await hashFile(task.filePath, undefined, signal);
94
93
  entries.push({ path: task.relativePath, hash: fileHash });
95
94
  filesHashed++;
96
- reportPeriodicProgress(onProgress, filesHashed, {
97
- throttleModulo: 25,
98
- total: totalFiles,
99
- });
95
+ onProgress?.({ current: filesHashed, total: totalFiles });
100
96
  }
101
97
  };
102
98
  await Promise.all(Array.from({ length: concurrency }, () => worker()));
103
- reportPeriodicProgress(onProgress, filesHashed, {
104
- throttleModulo: 25,
105
- total: totalFiles,
106
- force: true,
107
- });
99
+ onProgress?.({ current: filesHashed, total: totalFiles });
108
100
  assertNotAborted(signal);
109
101
  // Sort by path with byte-wise semantics for deterministic ordering.
110
102
  entries.sort(comparePaths);
@@ -123,7 +115,7 @@ async function hashDirectory(dirPath, options = {}) {
123
115
  async function handleCalculateHash(args, signal, onProgress) {
124
116
  const validPath = await validateExistingPath(args.path, signal);
125
117
  // Check if path is a directory or file
126
- const stats = await withAbort(fs.stat(validPath), signal);
118
+ const stats = await withAbort(stat(validPath), signal);
127
119
  if (stats.isDirectory()) {
128
120
  // Hash directory: composite hash of all files
129
121
  const { hash, fileCount } = await hashDirectory(validPath, {
@@ -141,10 +133,7 @@ async function handleCalculateHash(args, signal, onProgress) {
141
133
  else {
142
134
  // Hash single file
143
135
  const hash = await hashFile(validPath, 'hex', signal);
144
- reportPeriodicProgress(onProgress, 1, {
145
- throttleModulo: 25,
146
- force: true,
147
- });
136
+ onProgress?.({ current: 1 });
148
137
  return buildToolResponse(hash, {
149
138
  ok: true,
150
139
  path: validPath,
@@ -161,7 +150,7 @@ export function registerCalculateHashTool(server, options = {}) {
161
150
  timedSignal: {},
162
151
  context: { path: args.path },
163
152
  run: async (signal) => {
164
- const baseName = path.basename(args.path);
153
+ const baseName = basename(args.path);
165
154
  const progress = createToolProgressSession(extra, `🕮 hash: ${baseName}`);
166
155
  const progressWithMessage = ({ current, total, }) => {
167
156
  progress.update({
@@ -190,7 +179,7 @@ export function registerCalculateHashTool(server, options = {}) {
190
179
  throw error;
191
180
  }
192
181
  },
193
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
182
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
194
183
  });
195
184
  const wrappedHandler = wrapToolHandler(handler, {
196
185
  guard: options.isInitialized,
@@ -1,7 +1,7 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { withAbort } from '../lib/abort.js';
3
4
  import { ErrorCode, McpError } from '../lib/errors.js';
4
- import { withAbort } from '../lib/fs-helpers.js';
5
5
  import { validatePathForWrite } from '../lib/paths.js';
6
6
  import { CreateDirectoryInputSchema, CreateDirectoryOutputSchema, } from '../schemas.js';
7
7
  import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, IDEMPOTENT_WRITE_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -22,12 +22,14 @@ async function handleCreateDirectory(args, signal) {
22
22
  if (args.paths)
23
23
  allPaths.push(...args.paths);
24
24
  if (allPaths.length === 0) {
25
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'No paths provided to create.');
25
+ throw new McpError(ErrorCode.INVALID_INPUT, 'No paths provided to create.');
26
26
  }
27
27
  const validPaths = await Promise.all(allPaths.map((p) => validatePathForWrite(p, signal)));
28
- await Promise.all(validPaths.map((p) => withAbort(fs.mkdir(p, { recursive: true }), signal)));
28
+ await Promise.all(validPaths.map((p) => withAbort(mkdir(p, { recursive: true }), signal)));
29
+ const createdPath = validPaths.length === 1 ? validPaths[0] : undefined;
29
30
  return buildToolResponse(`Successfully created ${validPaths.length} director${validPaths.length === 1 ? 'y' : 'ies'}`, {
30
31
  ok: true,
32
+ ...(createdPath ? { path: createdPath } : {}),
31
33
  paths: validPaths,
32
34
  });
33
35
  }
@@ -39,20 +41,20 @@ export function registerCreateDirectoryTool(server, options = {}) {
39
41
  timedSignal: {},
40
42
  context: { path: args.path ?? args.paths?.[0] },
41
43
  run: (signal) => handleCreateDirectory(args, signal),
42
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? args.paths?.[0]),
44
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path ?? args.paths?.[0]),
43
45
  });
44
46
  const wrappedHandler = wrapToolHandler(handler, {
45
47
  guard: options.isInitialized,
46
48
  progressMessage: (args) => {
47
49
  if (args.path && !args.paths?.length) {
48
- return `🛠 mkdir: ${path.basename(args.path)}`;
50
+ return `🛠 mkdir: ${basename(args.path)}`;
49
51
  }
50
52
  const count = (args.path ? 1 : 0) + (args.paths?.length ?? 0);
51
53
  return `🛠 mkdir: ${count} directories`;
52
54
  },
53
55
  completionMessage: (args, result) => {
54
56
  if (args.path && !args.paths?.length) {
55
- const name = path.basename(args.path);
57
+ const name = basename(args.path);
56
58
  if (result.isError)
57
59
  return `🛠 mkdir: ${name} • failed`;
58
60
  return `🛠 mkdir: ${name}`;
@@ -1,7 +1,8 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
1
+ import { lstat, rm, rmdir } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { withAbort } from '../lib/abort.js';
3
4
  import { ErrorCode, isNodeError, McpError } from '../lib/errors.js';
4
- import { withAbort } from '../lib/fs-helpers.js';
5
+ import { Logger } from '../lib/logger.js';
5
6
  import { isAllowedDirectoryRoot, validatePathForWrite } from '../lib/paths.js';
6
7
  import { DeleteFileInputSchema, DeleteFileOutputSchema } from '../schemas.js';
7
8
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -19,11 +20,11 @@ export const DELETE_FILE_TOOL = {
19
20
  async function handleDeleteFile(args, signal) {
20
21
  const validPath = await validatePathForWrite(args.path, signal);
21
22
  if (isAllowedDirectoryRoot(validPath)) {
22
- throw new McpError(ErrorCode.E_ACCESS_DENIED, `Deleting a workspace root directory is not allowed: ${args.path}`);
23
+ throw new McpError(ErrorCode.ACCESS_DENIED, 'Deleting a workspace root directory is not allowed');
23
24
  }
24
25
  let stats;
25
26
  try {
26
- stats = await withAbort(fs.lstat(validPath), signal);
27
+ stats = await withAbort(lstat(validPath), signal);
27
28
  }
28
29
  catch (error) {
29
30
  if (isNodeError(error) &&
@@ -39,14 +40,15 @@ async function handleDeleteFile(args, signal) {
39
40
  if (stats.isDirectory() && !args.recursive) {
40
41
  // Use rmdir for non-recursive directory deletes so non-empty directories
41
42
  // consistently return ENOTEMPTY-style errors with actionable guidance.
42
- await withAbort(fs.rmdir(validPath), signal);
43
+ await withAbort(rmdir(validPath), signal);
43
44
  }
44
45
  else {
45
- await withAbort(fs.rm(validPath, {
46
+ await withAbort(rm(validPath, {
46
47
  recursive: args.recursive,
47
48
  force: args.ignoreIfNotExists,
48
49
  }), signal);
49
50
  }
51
+ Logger.info(`rm: ${args.path}`);
50
52
  return buildToolResponse(`Successfully deleted: ${args.path}`, {
51
53
  ok: true,
52
54
  path: validPath,
@@ -63,29 +65,29 @@ export function registerDeleteFileTool(server, options = {}) {
63
65
  onError: (error) => {
64
66
  if (isNodeError(error)) {
65
67
  if (error.code === 'ENOENT') {
66
- return buildToolErrorResponse(error, ErrorCode.E_NOT_FOUND, args.path);
68
+ return buildToolErrorResponse(error, ErrorCode.NOT_FOUND, args.path);
67
69
  }
68
70
  if (error.code === 'ENOTEMPTY') {
69
- return buildToolErrorResponse(new Error(`Directory is not empty: ${args.path}. Use recursive: true to delete non-empty directories.`), ErrorCode.E_INVALID_INPUT, args.path);
71
+ return buildToolErrorResponse(new Error('Directory not empty. Set recursive: true.'), ErrorCode.INVALID_INPUT, args.path);
70
72
  }
71
73
  if (error.code === 'EISDIR') {
72
- return buildToolErrorResponse(new Error(`Path is a directory: ${args.path}. Use recursive: true to delete directories.`), ErrorCode.E_INVALID_INPUT, args.path);
74
+ return buildToolErrorResponse(new Error('Path is a directory. Set recursive: true.'), ErrorCode.INVALID_INPUT, args.path);
73
75
  }
74
76
  if (error.code === 'EEXIST') {
75
- return buildToolErrorResponse(new Error(`Directory is not empty: ${args.path}. Use recursive: true to delete non-empty directories.`), ErrorCode.E_INVALID_INPUT, args.path);
77
+ return buildToolErrorResponse(new Error('Directory not empty. Set recursive: true.'), ErrorCode.INVALID_INPUT, args.path);
76
78
  }
77
79
  if (error.code === 'EPERM' || error.code === 'EACCES') {
78
- return buildToolErrorResponse(error, ErrorCode.E_PERMISSION_DENIED, args.path);
80
+ return buildToolErrorResponse(error, ErrorCode.PERMISSION_DENIED, args.path);
79
81
  }
80
82
  }
81
- return buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path);
83
+ return buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path);
82
84
  },
83
85
  });
84
86
  const wrappedHandler = wrapToolHandler(handler, {
85
87
  guard: options.isInitialized,
86
- progressMessage: (args) => `🛠 rm: ${path.basename(args.path)}`,
88
+ progressMessage: (args) => `🛠 rm: ${basename(args.path)}`,
87
89
  completionMessage: (args, result) => {
88
- const name = path.basename(args.path);
90
+ const name = basename(args.path);
89
91
  if (result.isError)
90
92
  return `🛠 rm: ${name} • failed`;
91
93
  return `🛠 rm: ${name}`;
@@ -1,9 +1,9 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
3
  import { formatPatch, structuredPatch } from 'diff';
4
+ import { withAbort } from '../lib/abort.js';
4
5
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
6
  import { ErrorCode, McpError } from '../lib/errors.js';
6
- import { withAbort } from '../lib/fs-helpers.js';
7
7
  import { validateExistingPath } from '../lib/paths.js';
8
8
  import { DiffFilesInputSchema, DiffFilesOutputSchema } from '../schemas.js';
9
9
  import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -35,7 +35,7 @@ function computeDiffStats(hunks) {
35
35
  function assertDiffFileSizeWithinLimit(filePath, size, maxFileSize) {
36
36
  if (size <= maxFileSize)
37
37
  return;
38
- throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for diff: ${filePath} (${size} bytes > ${maxFileSize} bytes).`, filePath, { size, maxFileSize });
38
+ throw new McpError(ErrorCode.TOO_LARGE, `File too large for diff (${size} bytes > ${maxFileSize} bytes).`, filePath, { size, maxFileSize });
39
39
  }
40
40
  async function handleDiffFiles(args, signal, resourceStore) {
41
41
  const maxFileSize = MAX_TEXT_FILE_SIZE;
@@ -44,17 +44,17 @@ async function handleDiffFiles(args, signal, resourceStore) {
44
44
  validateExistingPath(args.modified, signal),
45
45
  ]);
46
46
  const [originalStats, modifiedStats] = await Promise.all([
47
- withAbort(fs.stat(originalPath), signal),
48
- withAbort(fs.stat(modifiedPath), signal),
47
+ withAbort(stat(originalPath), signal),
48
+ withAbort(stat(modifiedPath), signal),
49
49
  ]);
50
50
  assertDiffFileSizeWithinLimit(originalPath, originalStats.size, maxFileSize);
51
51
  assertDiffFileSizeWithinLimit(modifiedPath, modifiedStats.size, maxFileSize);
52
52
  const [originalContent, modifiedContent] = await Promise.all([
53
- fs.readFile(originalPath, { encoding: 'utf-8', signal }),
54
- fs.readFile(modifiedPath, { encoding: 'utf-8', signal }),
53
+ readFile(originalPath, { encoding: 'utf-8', signal }),
54
+ readFile(modifiedPath, { encoding: 'utf-8', signal }),
55
55
  ]);
56
56
  const patchObj = await new Promise((resolve) => {
57
- structuredPatch(path.basename(originalPath), path.basename(modifiedPath), originalContent, modifiedContent, undefined, undefined, {
57
+ structuredPatch(basename(originalPath), basename(modifiedPath), originalContent, modifiedContent, undefined, undefined, {
58
58
  ...(args.context !== undefined ? { context: args.context } : {}),
59
59
  ignoreWhitespace: args.ignoreWhitespace,
60
60
  stripTrailingCr: args.stripTrailingCr,
@@ -65,7 +65,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
65
65
  });
66
66
  });
67
67
  if (!patchObj) {
68
- throw new McpError(ErrorCode.E_TIMEOUT, `Diff computation timed out or failed due to complexity.`, originalPath);
68
+ throw new McpError(ErrorCode.TIMEOUT, `Diff timed out or too complex.`, originalPath);
69
69
  }
70
70
  const isIdentical = patchObj.hunks.length === 0;
71
71
  const diffText = isIdentical ? '' : formatPatch(patchObj);
@@ -108,18 +108,18 @@ export function registerDiffFilesTool(server, options = {}) {
108
108
  timedSignal: {},
109
109
  context: { path: args.original },
110
110
  run: (signal) => handleDiffFiles(args, signal, options.resourceStore),
111
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.original),
111
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.original),
112
112
  });
113
113
  const wrappedHandler = wrapToolHandler(handler, {
114
114
  guard: options.isInitialized,
115
115
  progressMessage: (args) => {
116
- const n1 = path.basename(args.original);
117
- const n2 = path.basename(args.modified);
116
+ const n1 = basename(args.original);
117
+ const n2 = basename(args.modified);
118
118
  return `🕮 diff: ${n1} ⟷ ${n2}`;
119
119
  },
120
120
  completionMessage: (args, result) => {
121
- const n1 = path.basename(args.original);
122
- const n2 = path.basename(args.modified);
121
+ const n1 = basename(args.original);
122
+ const n2 = basename(args.modified);
123
123
  if (result.isError)
124
124
  return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
125
125
  const sc = result.structuredContent;
@@ -2,9 +2,11 @@ import { readFile, stat } from 'node:fs/promises';
2
2
  import { basename } from 'node:path';
3
3
  import { createTwoFilesPatch, diffLines } from 'diff';
4
4
  import RE2 from 're2';
5
+ import { withAbort } from '../lib/abort.js';
5
6
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
6
7
  import { ErrorCode, McpError } from '../lib/errors.js';
7
- import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
8
+ import { atomicWriteFile } from '../lib/fs-helpers.js';
9
+ import { Logger } from '../lib/logger.js';
8
10
  import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
9
11
  import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
10
12
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -161,7 +163,7 @@ async function loadEditableFile(requestedPath, signal) {
161
163
  assertAllowedFileAccess(requestedPath, validPath);
162
164
  const stats = await withAbort(stat(validPath), signal);
163
165
  if (stats.size > MAX_TEXT_FILE_SIZE) {
164
- throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for edit: ${requestedPath} (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, requestedPath, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
166
+ throw new McpError(ErrorCode.TOO_LARGE, `File too large for edit (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, requestedPath, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
165
167
  }
166
168
  const content = await readFile(validPath, { encoding: 'utf-8', signal });
167
169
  return { validPath, content };
@@ -215,13 +217,14 @@ async function handleEditFile(args, signal) {
215
217
  return buildToolResponse(`Dry run complete. ${editResult.appliedEdits} edits would be applied.`, structured);
216
218
  }
217
219
  if (editResult.appliedEdits === 0 && editResult.unmatchedEdits.length > 0) {
218
- throw new McpError(ErrorCode.E_INVALID_INPUT, `All ${editResult.unmatchedEdits.length} edits failed to match. Verify oldText includes exact content with surrounding context.`, args.path);
220
+ throw new McpError(ErrorCode.INVALID_INPUT, `All ${editResult.unmatchedEdits.length} edits failed. Verify oldText matches exact file content.`, args.path);
219
221
  }
220
222
  if (editResult.appliedEdits > 0) {
221
223
  await atomicWriteFile(validPath, editResult.content, {
222
224
  encoding: 'utf-8',
223
225
  signal,
224
226
  });
227
+ Logger.info(`edit: ${args.path} (${editResult.appliedEdits} edits, +${editResult.linesAdded}/-${editResult.linesRemoved})`);
225
228
  }
226
229
  return buildToolResponse(buildEditMessage(args.path, editResult), structured);
227
230
  }
@@ -233,7 +236,7 @@ export function registerEditFileTool(server, options = {}) {
233
236
  timedSignal: {},
234
237
  context: { path: args.path },
235
238
  run: (signal) => handleEditFile(args, signal),
236
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
239
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
237
240
  });
238
241
  const wrappedHandler = wrapToolHandler(handler, {
239
242
  guard: options.isInitialized,
@@ -1,5 +1,5 @@
1
- import * as path from 'node:path';
2
1
  import { randomUUID } from 'node:crypto';
2
+ import { basename } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { DEFAULT_EXCLUDE_PATTERNS, MAX_LIST_ENTRIES, } from '../lib/constants.js';
5
5
  import { ErrorCode, McpError } from '../lib/errors.js';
@@ -71,7 +71,7 @@ function decodeListCursor(cursor) {
71
71
  catch {
72
72
  // fall through to throw
73
73
  }
74
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Invalid cursor: the cursor value is malformed or expired.');
74
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Invalid or expired cursor.');
75
75
  }
76
76
  function resolveNextListCursor(snapshotId, offset, pageSize, totalEntries) {
77
77
  if (!snapshotId)
@@ -166,7 +166,7 @@ async function handleListDirectory(args, signal) {
166
166
  const cursor = decodeListCursor(args.cursor);
167
167
  const snapshot = listSnapshots.get(cursor.snapshotId);
168
168
  if (snapshot?.fingerprint !== fingerprint) {
169
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Invalid cursor: the cursor value is malformed or expired.');
169
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Invalid or expired cursor.');
170
170
  }
171
171
  const { offset, snapshotId: storedSnapshotId } = cursor;
172
172
  cursorOffset = offset;
@@ -200,13 +200,13 @@ export function registerListDirectoryTool(server, options = {}) {
200
200
  outputSchema: ListDirectoryOutputSchema,
201
201
  context: { path: args.path ?? '.' },
202
202
  run: (signal) => handleListDirectory(args, signal),
203
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_DIRECTORY, args.path ?? '.'),
203
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.NOT_DIRECTORY, args.path ?? '.'),
204
204
  });
205
205
  const wrappedHandler = wrapToolHandler(handler, {
206
206
  guard: options.isInitialized,
207
- progressMessage: (args) => `≣ ls: ${args.path ? path.basename(args.path) : '.'}`,
207
+ progressMessage: (args) => `≣ ls: ${args.path ? basename(args.path) : '.'}`,
208
208
  completionMessage: (args, result) => {
209
- const base = args.path ? path.basename(args.path) : '.';
209
+ const base = args.path ? basename(args.path) : '.';
210
210
  if (result.isError)
211
211
  return `≣ ls: ${base} • failed`;
212
212
  const sc = result.structuredContent;
@@ -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}`;