@j0hanz/filesystem-mcp 1.13.2 → 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 (74) hide show
  1. package/README.md +162 -145
  2. package/dist/cli.js +2 -2
  3. package/dist/completions.js +54 -51
  4. package/dist/config.d.ts +13 -14
  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 +64 -41
  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 +69 -22
  17. package/dist/lib/file-operations/search.d.ts +0 -1
  18. package/dist/lib/file-operations/search.js +87 -95
  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 +29 -108
  22. package/dist/lib/globs.d.ts +2 -0
  23. package/dist/lib/globs.js +19 -0
  24. package/dist/lib/logger.d.ts +28 -0
  25. package/dist/lib/logger.js +91 -0
  26. package/dist/lib/observability.d.ts +7 -0
  27. package/dist/lib/observability.js +19 -9
  28. package/dist/lib/paths.js +55 -55
  29. package/dist/lib/resource-store.js +4 -4
  30. package/dist/lib/utils.d.ts +0 -12
  31. package/dist/lib/utils.js +0 -13
  32. package/dist/lib/zod-codecs.d.ts +2 -0
  33. package/dist/lib/zod-codecs.js +18 -0
  34. package/dist/pkg-info.d.ts +1 -0
  35. package/dist/pkg-info.js +2 -2
  36. package/dist/prompts.js +3 -3
  37. package/dist/resources/generated-instructions.js +41 -41
  38. package/dist/resources/tool-catalog.js +33 -58
  39. package/dist/resources/tool-info.d.ts +0 -1
  40. package/dist/resources/tool-info.js +44 -67
  41. package/dist/resources/workflows.js +47 -19
  42. package/dist/resources.d.ts +1 -1
  43. package/dist/resources.js +4 -4
  44. package/dist/schemas.d.ts +185 -465
  45. package/dist/schemas.js +174 -206
  46. package/dist/server/bootstrap.d.ts +12 -11
  47. package/dist/server/bootstrap.js +95 -86
  48. package/dist/server/roots-manager.d.ts +5 -2
  49. package/dist/server/roots-manager.js +9 -7
  50. package/dist/server/task-store.d.ts +10 -0
  51. package/dist/server/task-store.js +73 -0
  52. package/dist/tools/apply-patch.js +39 -20
  53. package/dist/tools/calculate-hash.js +14 -27
  54. package/dist/tools/create-directory.js +11 -9
  55. package/dist/tools/delete-file.js +19 -19
  56. package/dist/tools/diff-files.js +16 -18
  57. package/dist/tools/edit-file.js +11 -5
  58. package/dist/tools/list-directory.js +16 -21
  59. package/dist/tools/move-file.js +105 -100
  60. package/dist/tools/read-multiple.js +15 -10
  61. package/dist/tools/read.js +6 -7
  62. package/dist/tools/replace-in-files.js +76 -115
  63. package/dist/tools/roots.js +3 -7
  64. package/dist/tools/search-content.js +158 -203
  65. package/dist/tools/search-files.js +59 -50
  66. package/dist/tools/shared.d.ts +10 -0
  67. package/dist/tools/shared.js +105 -36
  68. package/dist/tools/stat-many.js +15 -9
  69. package/dist/tools/stat.js +6 -6
  70. package/dist/tools/task-support.d.ts +10 -9
  71. package/dist/tools/task-support.js +94 -23
  72. package/dist/tools/tree.js +4 -4
  73. package/dist/tools/write-file.js +11 -12
  74. package/package.json +10 -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,
@@ -157,10 +146,11 @@ export function registerCalculateHashTool(server, options = {}) {
157
146
  const handler = (args, extra) => executeToolWithDiagnostics({
158
147
  toolName: 'calculate_hash',
159
148
  extra,
149
+ outputSchema: CalculateHashOutputSchema,
160
150
  timedSignal: {},
161
151
  context: { path: args.path },
162
152
  run: async (signal) => {
163
- const baseName = path.basename(args.path);
153
+ const baseName = basename(args.path);
164
154
  const progress = createToolProgressSession(extra, `🕮 hash: ${baseName}`);
165
155
  const progressWithMessage = ({ current, total, }) => {
166
156
  progress.update({
@@ -172,13 +162,10 @@ export function registerCalculateHashTool(server, options = {}) {
172
162
  try {
173
163
  const result = await handleCalculateHash(args, signal, progressWithMessage);
174
164
  const sc = result.structuredContent;
175
- const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
165
+ const totalFiles = sc.fileCount ?? 1;
176
166
  const finalCurrent = resolveFinalProgressCurrent(progress, totalFiles + 1);
177
167
  let suffix;
178
- if (!sc.ok) {
179
- suffix = 'failed';
180
- }
181
- else if (sc.fileCount !== undefined && sc.fileCount > 1) {
168
+ if (sc.fileCount !== undefined && sc.fileCount > 1) {
182
169
  suffix = `${sc.fileCount} files • ${(sc.hash ?? '').slice(0, 8)}…`;
183
170
  }
184
171
  else {
@@ -192,7 +179,7 @@ export function registerCalculateHashTool(server, options = {}) {
192
179
  throw error;
193
180
  }
194
181
  },
195
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
182
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
196
183
  });
197
184
  const wrappedHandler = wrapToolHandler(handler, {
198
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';
@@ -13,7 +13,6 @@ export const CREATE_DIRECTORY_TOOL = {
13
13
  inputSchema: CreateDirectoryInputSchema,
14
14
  outputSchema: CreateDirectoryOutputSchema,
15
15
  annotations: IDEMPOTENT_WRITE_TOOL_ANNOTATIONS,
16
- nuances: ['Succeeds silently if the directory already exists (idempotent).'],
17
16
  taskSupport: 'forbidden',
18
17
  };
19
18
  async function handleCreateDirectory(args, signal) {
@@ -23,12 +22,14 @@ async function handleCreateDirectory(args, signal) {
23
22
  if (args.paths)
24
23
  allPaths.push(...args.paths);
25
24
  if (allPaths.length === 0) {
26
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'No paths provided to create.');
25
+ throw new McpError(ErrorCode.INVALID_INPUT, 'No paths provided to create.');
27
26
  }
28
27
  const validPaths = await Promise.all(allPaths.map((p) => validatePathForWrite(p, signal)));
29
- 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;
30
30
  return buildToolResponse(`Successfully created ${validPaths.length} director${validPaths.length === 1 ? 'y' : 'ies'}`, {
31
31
  ok: true,
32
+ ...(createdPath ? { path: createdPath } : {}),
32
33
  paths: validPaths,
33
34
  });
34
35
  }
@@ -36,23 +37,24 @@ export function registerCreateDirectoryTool(server, options = {}) {
36
37
  const handler = (args, extra) => executeToolWithDiagnostics({
37
38
  toolName: 'mkdir',
38
39
  extra,
40
+ outputSchema: CreateDirectoryOutputSchema,
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';
@@ -13,20 +14,17 @@ export const DELETE_FILE_TOOL = {
13
14
  inputSchema: DeleteFileInputSchema,
14
15
  outputSchema: DeleteFileOutputSchema,
15
16
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
16
- gotchas: [
17
- 'No undo — deletion is permanent.',
18
- 'Non-empty directories require `recursive=true`.',
19
- ],
17
+ gotchas: ['Non-empty directories require `recursive=true`.'],
20
18
  taskSupport: 'forbidden',
21
19
  };
22
20
  async function handleDeleteFile(args, signal) {
23
21
  const validPath = await validatePathForWrite(args.path, signal);
24
22
  if (isAllowedDirectoryRoot(validPath)) {
25
- 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');
26
24
  }
27
25
  let stats;
28
26
  try {
29
- stats = await withAbort(fs.lstat(validPath), signal);
27
+ stats = await withAbort(lstat(validPath), signal);
30
28
  }
31
29
  catch (error) {
32
30
  if (isNodeError(error) &&
@@ -42,14 +40,15 @@ async function handleDeleteFile(args, signal) {
42
40
  if (stats.isDirectory() && !args.recursive) {
43
41
  // Use rmdir for non-recursive directory deletes so non-empty directories
44
42
  // consistently return ENOTEMPTY-style errors with actionable guidance.
45
- await withAbort(fs.rmdir(validPath), signal);
43
+ await withAbort(rmdir(validPath), signal);
46
44
  }
47
45
  else {
48
- await withAbort(fs.rm(validPath, {
46
+ await withAbort(rm(validPath, {
49
47
  recursive: args.recursive,
50
48
  force: args.ignoreIfNotExists,
51
49
  }), signal);
52
50
  }
51
+ Logger.info(`rm: ${args.path}`);
53
52
  return buildToolResponse(`Successfully deleted: ${args.path}`, {
54
53
  ok: true,
55
54
  path: validPath,
@@ -59,35 +58,36 @@ export function registerDeleteFileTool(server, options = {}) {
59
58
  const handler = (args, extra) => executeToolWithDiagnostics({
60
59
  toolName: 'rm',
61
60
  extra,
61
+ outputSchema: DeleteFileOutputSchema,
62
62
  timedSignal: {},
63
63
  context: { path: args.path },
64
64
  run: (signal) => handleDeleteFile(args, signal),
65
65
  onError: (error) => {
66
66
  if (isNodeError(error)) {
67
67
  if (error.code === 'ENOENT') {
68
- return buildToolErrorResponse(error, ErrorCode.E_NOT_FOUND, args.path);
68
+ return buildToolErrorResponse(error, ErrorCode.NOT_FOUND, args.path);
69
69
  }
70
70
  if (error.code === 'ENOTEMPTY') {
71
- 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);
72
72
  }
73
73
  if (error.code === 'EISDIR') {
74
- 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);
75
75
  }
76
76
  if (error.code === 'EEXIST') {
77
- 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);
78
78
  }
79
79
  if (error.code === 'EPERM' || error.code === 'EACCES') {
80
- return buildToolErrorResponse(error, ErrorCode.E_PERMISSION_DENIED, args.path);
80
+ return buildToolErrorResponse(error, ErrorCode.PERMISSION_DENIED, args.path);
81
81
  }
82
82
  }
83
- return buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path);
83
+ return buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path);
84
84
  },
85
85
  });
86
86
  const wrappedHandler = wrapToolHandler(handler, {
87
87
  guard: options.isInitialized,
88
- progressMessage: (args) => `🛠 rm: ${path.basename(args.path)}`,
88
+ progressMessage: (args) => `🛠 rm: ${basename(args.path)}`,
89
89
  completionMessage: (args, result) => {
90
- const name = path.basename(args.path);
90
+ const name = basename(args.path);
91
91
  if (result.isError)
92
92
  return `🛠 rm: ${name} • failed`;
93
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';
@@ -17,7 +17,6 @@ export const DIFF_FILES_TOOL = {
17
17
  inputSchema: DiffFilesInputSchema,
18
18
  outputSchema: DiffFilesOutputSchema,
19
19
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
20
- gotchas: ['`isIdentical=true` means no hunks (`@@`) and empty diff.'],
21
20
  taskSupport: 'forbidden',
22
21
  };
23
22
  function computeDiffStats(hunks) {
@@ -36,7 +35,7 @@ function computeDiffStats(hunks) {
36
35
  function assertDiffFileSizeWithinLimit(filePath, size, maxFileSize) {
37
36
  if (size <= maxFileSize)
38
37
  return;
39
- 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 });
40
39
  }
41
40
  async function handleDiffFiles(args, signal, resourceStore) {
42
41
  const maxFileSize = MAX_TEXT_FILE_SIZE;
@@ -45,17 +44,17 @@ async function handleDiffFiles(args, signal, resourceStore) {
45
44
  validateExistingPath(args.modified, signal),
46
45
  ]);
47
46
  const [originalStats, modifiedStats] = await Promise.all([
48
- withAbort(fs.stat(originalPath), signal),
49
- withAbort(fs.stat(modifiedPath), signal),
47
+ withAbort(stat(originalPath), signal),
48
+ withAbort(stat(modifiedPath), signal),
50
49
  ]);
51
50
  assertDiffFileSizeWithinLimit(originalPath, originalStats.size, maxFileSize);
52
51
  assertDiffFileSizeWithinLimit(modifiedPath, modifiedStats.size, maxFileSize);
53
52
  const [originalContent, modifiedContent] = await Promise.all([
54
- fs.readFile(originalPath, { encoding: 'utf-8', signal }),
55
- fs.readFile(modifiedPath, { encoding: 'utf-8', signal }),
53
+ readFile(originalPath, { encoding: 'utf-8', signal }),
54
+ readFile(modifiedPath, { encoding: 'utf-8', signal }),
56
55
  ]);
57
56
  const patchObj = await new Promise((resolve) => {
58
- structuredPatch(path.basename(originalPath), path.basename(modifiedPath), originalContent, modifiedContent, undefined, undefined, {
57
+ structuredPatch(basename(originalPath), basename(modifiedPath), originalContent, modifiedContent, undefined, undefined, {
59
58
  ...(args.context !== undefined ? { context: args.context } : {}),
60
59
  ignoreWhitespace: args.ignoreWhitespace,
61
60
  stripTrailingCr: args.stripTrailingCr,
@@ -66,7 +65,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
66
65
  });
67
66
  });
68
67
  if (!patchObj) {
69
- 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);
70
69
  }
71
70
  const isIdentical = patchObj.hunks.length === 0;
72
71
  const diffText = isIdentical ? '' : formatPatch(patchObj);
@@ -105,26 +104,25 @@ export function registerDiffFilesTool(server, options = {}) {
105
104
  const handler = (args, extra) => executeToolWithDiagnostics({
106
105
  toolName: 'diff_files',
107
106
  extra,
107
+ outputSchema: DiffFilesOutputSchema,
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;
126
- if (!sc.ok)
127
- return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
128
126
  if (sc.isIdentical)
129
127
  return `🕮 diff: ${n1} ⟷ ${n2} • identical`;
130
128
  const added = sc.linesAdded ?? 0;
@@ -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';
@@ -19,7 +21,6 @@ export const EDIT_FILE_TOOL = {
19
21
  outputSchema: EditFileOutputSchema,
20
22
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
21
23
  nuances: ['Each edit applies to the output of the previous edit.'],
22
- gotchas: ['Unmatched `oldText` entries listed in `unmatchedEdits`.'],
23
24
  taskSupport: 'forbidden',
24
25
  };
25
26
  function escapeRegExp(string) {
@@ -104,7 +105,7 @@ function mergeLineRange(currentRange, content, matchStartIndex, newText) {
104
105
  }
105
106
  function buildStructuredEditOutput(validPath, result) {
106
107
  return {
107
- ok: true,
108
+ ok: result.appliedEdits > 0 || result.unmatchedEdits.length === 0,
108
109
  path: validPath,
109
110
  appliedEdits: result.appliedEdits,
110
111
  ...(result.appliedEdits > 0
@@ -162,7 +163,7 @@ async function loadEditableFile(requestedPath, signal) {
162
163
  assertAllowedFileAccess(requestedPath, validPath);
163
164
  const stats = await withAbort(stat(validPath), signal);
164
165
  if (stats.size > MAX_TEXT_FILE_SIZE) {
165
- 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 });
166
167
  }
167
168
  const content = await readFile(validPath, { encoding: 'utf-8', signal });
168
169
  return { validPath, content };
@@ -215,11 +216,15 @@ async function handleEditFile(args, signal) {
215
216
  }
216
217
  return buildToolResponse(`Dry run complete. ${editResult.appliedEdits} edits would be applied.`, structured);
217
218
  }
219
+ if (editResult.appliedEdits === 0 && editResult.unmatchedEdits.length > 0) {
220
+ throw new McpError(ErrorCode.INVALID_INPUT, `All ${editResult.unmatchedEdits.length} edits failed. Verify oldText matches exact file content.`, args.path);
221
+ }
218
222
  if (editResult.appliedEdits > 0) {
219
223
  await atomicWriteFile(validPath, editResult.content, {
220
224
  encoding: 'utf-8',
221
225
  signal,
222
226
  });
227
+ Logger.info(`edit: ${args.path} (${editResult.appliedEdits} edits, +${editResult.linesAdded}/-${editResult.linesRemoved})`);
223
228
  }
224
229
  return buildToolResponse(buildEditMessage(args.path, editResult), structured);
225
230
  }
@@ -227,10 +232,11 @@ export function registerEditFileTool(server, options = {}) {
227
232
  const handler = (args, extra) => executeToolWithDiagnostics({
228
233
  toolName: 'edit',
229
234
  extra,
235
+ outputSchema: EditFileOutputSchema,
230
236
  timedSignal: {},
231
237
  context: { path: args.path },
232
238
  run: (signal) => handleEditFile(args, signal),
233
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
239
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
234
240
  });
235
241
  const wrappedHandler = wrapToolHandler(handler, {
236
242
  guard: options.isInitialized,
@@ -1,8 +1,10 @@
1
- import * as path from 'node:path';
2
1
  import { randomUUID } from 'node:crypto';
2
+ import { basename } from 'node:path';
3
+ import { z } from 'zod';
3
4
  import { DEFAULT_EXCLUDE_PATTERNS, MAX_LIST_ENTRIES, } from '../lib/constants.js';
4
5
  import { ErrorCode, McpError } from '../lib/errors.js';
5
6
  import { listDirectory } from '../lib/file-operations/metadata.js';
7
+ import { createBase64JsonCodec } from '../lib/zod-codecs.js';
6
8
  import { formatOperationSummary, joinLines } from '../config.js';
7
9
  import { ListDirectoryInputSchema, ListDirectoryOutputSchema, } from '../schemas.js';
8
10
  import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -23,6 +25,11 @@ const LIST_CURSOR_TTL_MS = parseInt(process.env['FS_CONTEXT_LIST_CURSOR_TTL_MS']
23
25
  5 * 60 * 1000;
24
26
  const listSnapshots = new Map();
25
27
  const listSnapshotTimers = new Map();
28
+ const ListCursorPayloadSchema = z.strictObject({
29
+ snapshotId: z.string().min(1),
30
+ offset: z.int().min(0),
31
+ });
32
+ const ListCursorCodec = createBase64JsonCodec(ListCursorPayloadSchema);
26
33
  function buildListFingerprint(args) {
27
34
  return JSON.stringify({
28
35
  path: resolvePathOrRoot(args.path),
@@ -55,27 +62,16 @@ function storeListSnapshot(snapshot) {
55
62
  return snapshotId;
56
63
  }
57
64
  function encodeListCursor(payload) {
58
- return Buffer.from(JSON.stringify(payload)).toString('base64url');
65
+ return z.encode(ListCursorCodec, payload);
59
66
  }
60
67
  function decodeListCursor(cursor) {
61
68
  try {
62
- const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
63
- if (typeof parsed === 'object' &&
64
- parsed !== null &&
65
- typeof parsed.snapshotId === 'string' &&
66
- typeof parsed.offset === 'number') {
67
- const payload = parsed;
68
- if (payload.snapshotId.length > 0 &&
69
- Number.isInteger(payload.offset) &&
70
- payload.offset >= 0) {
71
- return payload;
72
- }
73
- }
69
+ return ListCursorCodec.parse(cursor);
74
70
  }
75
71
  catch {
76
72
  // fall through to throw
77
73
  }
78
- 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.');
79
75
  }
80
76
  function resolveNextListCursor(snapshotId, offset, pageSize, totalEntries) {
81
77
  if (!snapshotId)
@@ -170,7 +166,7 @@ async function handleListDirectory(args, signal) {
170
166
  const cursor = decodeListCursor(args.cursor);
171
167
  const snapshot = listSnapshots.get(cursor.snapshotId);
172
168
  if (snapshot?.fingerprint !== fingerprint) {
173
- 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.');
174
170
  }
175
171
  const { offset, snapshotId: storedSnapshotId } = cursor;
176
172
  cursorOffset = offset;
@@ -201,20 +197,19 @@ export function registerListDirectoryTool(server, options = {}) {
201
197
  const handler = (args, extra) => executeToolWithDiagnostics({
202
198
  toolName: 'ls',
203
199
  extra,
200
+ outputSchema: ListDirectoryOutputSchema,
204
201
  context: { path: args.path ?? '.' },
205
202
  run: (signal) => handleListDirectory(args, signal),
206
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_DIRECTORY, args.path ?? '.'),
203
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.NOT_DIRECTORY, args.path ?? '.'),
207
204
  });
208
205
  const wrappedHandler = wrapToolHandler(handler, {
209
206
  guard: options.isInitialized,
210
- progressMessage: (args) => `≣ ls: ${args.path ? path.basename(args.path) : '.'}`,
207
+ progressMessage: (args) => `≣ ls: ${args.path ? basename(args.path) : '.'}`,
211
208
  completionMessage: (args, result) => {
212
- const base = args.path ? path.basename(args.path) : '.';
209
+ const base = args.path ? basename(args.path) : '.';
213
210
  if (result.isError)
214
211
  return `≣ ls: ${base} • failed`;
215
212
  const sc = result.structuredContent;
216
- if (!sc.ok)
217
- return `≣ ls: ${base} • failed`;
218
213
  const count = sc.totalEntries ?? 0;
219
214
  return `≣ ls: ${base} • ${count} ${count === 1 ? 'entry' : 'entries'}`;
220
215
  },