@j0hanz/filesystem-mcp 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +15 -15
  2. package/dist/completions.js +140 -115
  3. package/dist/lib/constants.d.ts +1 -0
  4. package/dist/lib/constants.js +2 -0
  5. package/dist/lib/file-operations/metadata.d.ts +5 -1
  6. package/dist/lib/file-operations/metadata.js +14 -1
  7. package/dist/lib/file-operations/search.d.ts +7 -5
  8. package/dist/lib/file-operations/search.js +64 -32
  9. package/dist/lib/fs-helpers.d.ts +3 -1
  10. package/dist/lib/fs-helpers.js +63 -0
  11. package/dist/lib/paths.d.ts +8 -0
  12. package/dist/lib/paths.js +119 -64
  13. package/dist/lib/resource-store.d.ts +2 -0
  14. package/dist/lib/resource-store.js +58 -17
  15. package/dist/prompts.d.ts +2 -0
  16. package/dist/prompts.js +51 -0
  17. package/dist/resources/generated-instructions.js +36 -9
  18. package/dist/resources/tool-catalog.js +30 -7
  19. package/dist/resources/tool-info.d.ts +4 -0
  20. package/dist/resources/tool-info.js +21 -3
  21. package/dist/resources/workflows.js +17 -5
  22. package/dist/schemas.d.ts +47 -12
  23. package/dist/schemas.js +75 -18
  24. package/dist/server/bootstrap.js +103 -91
  25. package/dist/server/roots-manager.d.ts +3 -0
  26. package/dist/server/roots-manager.js +15 -3
  27. package/dist/tools/apply-patch.js +135 -31
  28. package/dist/tools/calculate-hash.js +13 -8
  29. package/dist/tools/create-directory.js +14 -3
  30. package/dist/tools/delete-file.js +1 -0
  31. package/dist/tools/diff-files.js +26 -8
  32. package/dist/tools/edit-file.js +11 -8
  33. package/dist/tools/list-directory.js +1 -6
  34. package/dist/tools/move-file.js +39 -7
  35. package/dist/tools/read-multiple.js +9 -1
  36. package/dist/tools/read.js +38 -6
  37. package/dist/tools/replace-in-files.js +72 -25
  38. package/dist/tools/roots.js +1 -0
  39. package/dist/tools/search-content.js +76 -48
  40. package/dist/tools/search-files.js +6 -7
  41. package/dist/tools/shared.d.ts +2 -1
  42. package/dist/tools/shared.js +36 -20
  43. package/dist/tools/stat-many.js +1 -1
  44. package/dist/tools/stat.js +4 -0
  45. package/dist/tools/task-support.js +4 -12
  46. package/dist/tools/tree.js +4 -0
  47. package/dist/tools/write-file.js +4 -2
  48. package/package.json +17 -8
@@ -3,7 +3,7 @@ import { InitializedNotificationSchema, RootsListChangedNotificationSchema, } fr
3
3
  import { z } from 'zod';
4
4
  import { formatUnknownErrorMessage } from '../lib/errors.js';
5
5
  import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
6
- import { getAllowedDirectories, getValidRootDirectories, isPathWithinDirectories, normalizePath, setAllowedDirectoriesResolved, } from '../lib/paths.js';
6
+ import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
7
7
  import { isRecord } from '../lib/utils.js';
8
8
  import { logToMcp } from './bootstrap.js';
9
9
  const ROOTS_TIMEOUT_MS = 5000;
@@ -83,6 +83,10 @@ async function filterRootsWithinBaseline(roots, baseline, signal) {
83
83
  export class RootsManager {
84
84
  rootsUpdateTimeout;
85
85
  rootDirectories = [];
86
+ allowedDirectoriesState = {
87
+ primary: [],
88
+ expanded: [],
89
+ };
86
90
  clientInitialized = false;
87
91
  // Set to true when an update is in progress, to prevent concurrent executions. If a change arrives while true, we queue a single retry after completion to ensure the last-known state is applied. This
88
92
  updatingRoots = false;
@@ -103,8 +107,14 @@ export class RootsManager {
103
107
  this.rootsUpdateTimeout = undefined;
104
108
  }
105
109
  }
110
+ getAllowedDirectoriesState() {
111
+ return {
112
+ primary: [...this.allowedDirectoriesState.primary],
113
+ expanded: [...this.allowedDirectoriesState.expanded],
114
+ };
115
+ }
106
116
  logMissingDirectoriesIfNeeded(server) {
107
- if (getAllowedDirectories().length === 0) {
117
+ if (this.allowedDirectoriesState.expanded.length === 0) {
108
118
  this.logMissingDirectories(server);
109
119
  }
110
120
  }
@@ -130,7 +140,9 @@ export class RootsManager {
130
140
  ? await filterRootsWithinBaseline(this.rootDirectories, baseline, signal)
131
141
  : this.rootDirectories;
132
142
  const combined = [...baseline, ...rootsToInclude];
133
- await setAllowedDirectoriesResolved(combined, signal);
143
+ const nextState = await resolveAllowedDirectoriesState(combined, signal);
144
+ this.allowedDirectoriesState = nextState;
145
+ setAllowedDirectoriesStateResolved(nextState);
134
146
  }
135
147
  finally {
136
148
  cleanup();
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { applyPatch } from 'diff';
3
+ import { applyPatch, parsePatch } from 'diff';
4
4
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
- import { ErrorCode, McpError } from '../lib/errors.js';
5
+ import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
6
6
  import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
7
7
  import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
8
8
  import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
@@ -11,13 +11,18 @@ import { registerToolTaskIfAvailable } from './task-support.js';
11
11
  export const APPLY_PATCH_TOOL = {
12
12
  name: 'apply_patch',
13
13
  title: 'Apply Patch',
14
- description: 'Apply a unified diff patch to a file. ' +
14
+ description: 'Apply a unified diff patch to one or more files. ' +
15
+ 'Single-file: throws on failure. Multi-file: best-effort per file with `results[]`. ' +
15
16
  'Workflow: `diff_files` \u2192 `apply_patch(dryRun:true)` \u2192 `apply_patch`. ' +
16
17
  'On failure, regenerate the patch from current file content.',
17
18
  inputSchema: ApplyPatchInputSchema,
18
19
  outputSchema: ApplyPatchOutputSchema,
19
20
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
21
+ nuances: [
22
+ 'Multi-file patches use `path` as base directory; per-file results in `results[]`.',
23
+ ],
20
24
  gotchas: ['Patch must include valid hunk headers; use `dryRun=true` first.'],
25
+ taskSupport: 'optional',
21
26
  };
22
27
  function assertPatchTargetSizeWithinLimit(filePath, size, maxFileSize) {
23
28
  if (size <= maxFileSize)
@@ -33,37 +38,138 @@ function assertPatchHasHunks(patch) {
33
38
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch must include unified hunk headers (e.g., @@ -1,2 +1,2 @@).');
34
39
  }
35
40
  }
36
- async function handleApplyPatch(args, signal) {
37
- const maxFileSize = MAX_TEXT_FILE_SIZE;
38
- const validPath = await validateExistingPath(args.path, signal);
39
- assertAllowedFileAccess(args.path, validPath);
41
+ function countStructuredPatchStats(diff) {
42
+ let linesAdded = 0;
43
+ let linesRemoved = 0;
44
+ for (const hunk of diff.hunks) {
45
+ for (const line of hunk.lines) {
46
+ if (line.startsWith('+'))
47
+ linesAdded++;
48
+ else if (line.startsWith('-'))
49
+ linesRemoved++;
50
+ }
51
+ }
52
+ return { hunksApplied: diff.hunks.length, linesAdded, linesRemoved };
53
+ }
54
+ function stripGitPrefix(fileName) {
55
+ return fileName.startsWith('a/') || fileName.startsWith('b/')
56
+ ? fileName.slice(2)
57
+ : fileName;
58
+ }
59
+ function extractPatchTargetPath(diff) {
60
+ if (diff.newFileName)
61
+ return stripGitPrefix(diff.newFileName);
62
+ if (diff.oldFileName)
63
+ return stripGitPrefix(diff.oldFileName);
64
+ return undefined;
65
+ }
66
+ async function applyPatchToFile(filePath, diff, options, signal) {
67
+ const validPath = await validateExistingPath(filePath, signal);
68
+ assertAllowedFileAccess(filePath, validPath);
40
69
  const stats = await withAbort(fs.stat(validPath), signal);
41
- assertPatchTargetSizeWithinLimit(validPath, stats.size, maxFileSize);
70
+ assertPatchTargetSizeWithinLimit(validPath, stats.size, MAX_TEXT_FILE_SIZE);
42
71
  const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
43
- const fuzzFactor = args.fuzzFactor ?? 0;
44
- assertPatchHasHunks(args.patch);
45
- const patched = applyPatch(content, args.patch, {
46
- fuzzFactor,
47
- autoConvertLineEndings: args.autoConvertLineEndings,
72
+ const patched = applyPatch(content, diff, {
73
+ fuzzFactor: options.fuzzFactor,
74
+ autoConvertLineEndings: options.autoConvertLineEndings,
48
75
  });
49
76
  if (patched === false) {
50
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch application failed. The file content may have changed or patch context is insufficient. Generate a fresh patch via diff_files against the current file, then retry. If differences are minor, enable fuzzy matching with the fuzzFactor parameter.');
77
+ return {
78
+ path: validPath,
79
+ applied: false,
80
+ error: 'Patch application failed',
81
+ };
51
82
  }
52
83
  if (patched === content) {
53
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch had no effect — the file content is unchanged after applying. The patch may not match the current file content. Generate a fresh patch via diff_files and retry.');
84
+ return { path: validPath, applied: false, error: 'Patch had no effect' };
54
85
  }
55
- if (args.dryRun) {
56
- return buildToolResponse('Dry run successful. Patch can be applied.', {
57
- ok: true,
58
- path: validPath,
59
- applied: true,
60
- });
86
+ const patchStats = countStructuredPatchStats(diff);
87
+ if (!options.dryRun) {
88
+ await atomicWriteFile(validPath, patched, { encoding: 'utf-8', signal });
89
+ }
90
+ return { path: validPath, applied: true, ...patchStats };
91
+ }
92
+ async function handleMultiFilePatch(basePath, parsed, options, signal) {
93
+ const validBase = await validateExistingPath(basePath, signal);
94
+ const results = [];
95
+ for (const diff of parsed) {
96
+ const fileName = extractPatchTargetPath(diff);
97
+ if (!fileName) {
98
+ results.push({
99
+ path: '<unknown>',
100
+ applied: false,
101
+ error: 'Missing file name in patch header',
102
+ });
103
+ continue;
104
+ }
105
+ const filePath = path.resolve(validBase, fileName);
106
+ try {
107
+ const result = await applyPatchToFile(filePath, diff, options, signal);
108
+ results.push({ ...result, path: fileName });
109
+ }
110
+ catch (error) {
111
+ results.push({
112
+ path: fileName,
113
+ applied: false,
114
+ error: formatUnknownErrorMessage(error),
115
+ });
116
+ }
117
+ }
118
+ const totals = results.reduce((acc, r) => {
119
+ if (r.applied) {
120
+ acc.applied++;
121
+ acc.hunks += r.hunksApplied ?? 0;
122
+ acc.added += r.linesAdded ?? 0;
123
+ acc.removed += r.linesRemoved ?? 0;
124
+ }
125
+ return acc;
126
+ }, { applied: 0, hunks: 0, added: 0, removed: 0 });
127
+ const label = options.dryRun ? ' (dry run)' : '';
128
+ const text = `Applied ${totals.applied}/${parsed.length} file patches${label}`;
129
+ return buildToolResponse(text, {
130
+ ok: totals.applied === parsed.length,
131
+ path: basePath,
132
+ applied: totals.applied > 0,
133
+ hunksApplied: totals.hunks,
134
+ linesAdded: totals.added,
135
+ linesRemoved: totals.removed,
136
+ results,
137
+ });
138
+ }
139
+ async function handleApplyPatch(args, signal) {
140
+ assertPatchHasHunks(args.patch);
141
+ const fuzzFactor = args.fuzzFactor ?? 0;
142
+ const parsed = parsePatch(args.patch);
143
+ const options = {
144
+ dryRun: args.dryRun,
145
+ fuzzFactor,
146
+ autoConvertLineEndings: args.autoConvertLineEndings,
147
+ };
148
+ // Multi-file patch: best-effort per file
149
+ if (parsed.length > 1) {
150
+ return handleMultiFilePatch(args.path, parsed, options, signal);
151
+ }
152
+ // Single-file patch: delegate to shared helper, then assert success
153
+ const diff = parsed[0];
154
+ if (!diff) {
155
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'No patch content found.');
156
+ }
157
+ const result = await applyPatchToFile(args.path, diff, options, signal);
158
+ if (!result.applied) {
159
+ throw new McpError(ErrorCode.E_INVALID_INPUT, result.error === 'Patch had no effect'
160
+ ? 'Patch had no effect \u2014 the file content is unchanged after applying. The patch may not match the current file content. Generate a fresh patch via diff_files and retry.'
161
+ : 'Patch application failed. The file content may have changed or patch context is insufficient. Generate a fresh patch via diff_files against the current file, then retry. If differences are minor, enable fuzzy matching with the fuzzFactor parameter.');
61
162
  }
62
- await atomicWriteFile(validPath, patched, { encoding: 'utf-8', signal });
63
- return buildToolResponse(`Successfully patched ${args.path}`, {
163
+ const text = args.dryRun
164
+ ? 'Dry run successful. Patch can be applied.'
165
+ : `Successfully patched ${args.path}`;
166
+ return buildToolResponse(text, {
64
167
  ok: true,
65
- path: validPath,
168
+ path: result.path,
66
169
  applied: true,
170
+ hunksApplied: result.hunksApplied,
171
+ linesAdded: result.linesAdded,
172
+ linesRemoved: result.linesRemoved,
67
173
  });
68
174
  }
69
175
  export function registerApplyPatchTool(server, options = {}) {
@@ -79,20 +185,18 @@ export function registerApplyPatchTool(server, options = {}) {
79
185
  guard: options.isInitialized,
80
186
  progressMessage: (args) => {
81
187
  const name = path.basename(args.path);
82
- return args.dryRun
83
- ? `🛠 apply_patch: ${name} [dry run]`
84
- : `🛠 apply_patch: ${name}`;
188
+ return args.dryRun ? `🛠 patch: ${name} [dry run]` : `🛠 patch: ${name}`;
85
189
  },
86
190
  completionMessage: (args, result) => {
87
191
  const name = path.basename(args.path);
88
192
  if (result.isError)
89
- return `🛠 apply_patch: ${name} • failed`;
193
+ return `🛠 patch: ${name} • failed`;
90
194
  const sc = result.structuredContent;
91
195
  if (!sc.ok)
92
- return `🛠 apply_patch: ${name} • failed`;
196
+ return `🛠 patch: ${name} • failed`;
93
197
  if (args.dryRun)
94
- return `🛠 apply_patch: ${name} • dry run OK`;
95
- return `🛠 apply_patch: ${name} • applied`;
198
+ return `🛠 patch: ${name} • dry run OK`;
199
+ return `🛠 patch: ${name} • applied`;
96
200
  },
97
201
  });
98
202
  const validatedHandler = withValidatedArgs(ApplyPatchInputSchema, wrappedHandler);
@@ -23,6 +23,7 @@ export const CALCULATE_HASH_TOOL = {
23
23
  nuances: [
24
24
  'Directory hashing respects root `.gitignore` and sorts paths for stable output.',
25
25
  ],
26
+ taskSupport: 'optional',
26
27
  };
27
28
  async function hashFile(filePath, encoding, signal) {
28
29
  assertNotAborted(signal);
@@ -87,6 +88,7 @@ async function hashDirectory(dirPath, options = {}) {
87
88
  const concurrency = Math.min(PARALLEL_CONCURRENCY, 8);
88
89
  const entries = [];
89
90
  let filesHashed = 0;
91
+ const totalFiles = filteredPaths.length;
90
92
  for (let i = 0; i < filteredPaths.length; i += concurrency) {
91
93
  assertNotAborted(signal);
92
94
  const batch = filteredPaths.slice(i, i + concurrency);
@@ -96,10 +98,14 @@ async function hashDirectory(dirPath, options = {}) {
96
98
  }));
97
99
  entries.push(...batchResults);
98
100
  filesHashed += batchResults.length;
99
- reportPeriodicProgress(onProgress, filesHashed, { throttleModulo: 25 });
101
+ reportPeriodicProgress(onProgress, filesHashed, {
102
+ throttleModulo: 25,
103
+ total: totalFiles,
104
+ });
100
105
  }
101
106
  reportPeriodicProgress(onProgress, filesHashed, {
102
107
  throttleModulo: 25,
108
+ total: totalFiles,
103
109
  force: true,
104
110
  });
105
111
  assertNotAborted(signal);
@@ -158,13 +164,12 @@ export function registerCalculateHashTool(server, options = {}) {
158
164
  context: { path: args.path },
159
165
  run: async (signal) => {
160
166
  const baseName = path.basename(args.path);
161
- const progress = createToolProgressSession(extra, `🕮 calculate_hash: ${baseName}`);
167
+ const progress = createToolProgressSession(extra, `🕮 hash: ${baseName}`);
162
168
  const progressWithMessage = ({ current, total, }) => {
163
- const fileWord = current === 1 ? 'file' : 'files';
164
169
  progress.update({
165
170
  current,
166
171
  ...(total !== undefined ? { total } : {}),
167
- message: `🕮 calculate_hash: ${baseName} [${current} ${fileWord} hashed]`,
172
+ message: `🕮 hash: ${baseName} [${current} files]`,
168
173
  });
169
174
  };
170
175
  try {
@@ -177,16 +182,16 @@ export function registerCalculateHashTool(server, options = {}) {
177
182
  suffix = 'failed';
178
183
  }
179
184
  else if (sc.fileCount !== undefined && sc.fileCount > 1) {
180
- suffix = `${sc.fileCount} files • ${(sc.hash ?? '').slice(0, 8)}...`;
185
+ suffix = `${sc.fileCount} files • ${(sc.hash ?? '').slice(0, 8)}…`;
181
186
  }
182
187
  else {
183
- suffix = `${(sc.hash ?? '').slice(0, 8)}...`;
188
+ suffix = `${(sc.hash ?? '').slice(0, 8)}…`;
184
189
  }
185
- progress.complete(`🕮 calculate_hash: ${baseName} • ${suffix}`, finalCurrent);
190
+ progress.complete(`🕮 hash: ${baseName} • ${suffix}`, finalCurrent);
186
191
  return result;
187
192
  }
188
193
  catch (error) {
189
- progress.fail(`🕮 calculate_hash: ${baseName} • failed`);
194
+ progress.fail(`🕮 hash: ${baseName} • failed`);
190
195
  throw error;
191
196
  }
192
197
  },
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
2
3
  import { ErrorCode, McpError } from '../lib/errors.js';
3
4
  import { withAbort } from '../lib/fs-helpers.js';
4
5
  import { validatePathForWrite } from '../lib/paths.js';
@@ -13,6 +14,7 @@ export const CREATE_DIRECTORY_TOOL = {
13
14
  outputSchema: CreateDirectoryOutputSchema,
14
15
  annotations: IDEMPOTENT_WRITE_TOOL_ANNOTATIONS,
15
16
  nuances: ['Succeeds silently if the directory already exists (idempotent).'],
17
+ taskSupport: 'optional',
16
18
  };
17
19
  export async function handleCreateDirectory(args, signal) {
18
20
  const allPaths = [];
@@ -42,14 +44,23 @@ export function registerCreateDirectoryTool(server, options = {}) {
42
44
  const wrappedHandler = wrapToolHandler(handler, {
43
45
  guard: options.isInitialized,
44
46
  progressMessage: (args) => {
47
+ if (args.path && !args.paths?.length) {
48
+ return `🛠 mkdir: ${path.basename(args.path)}`;
49
+ }
45
50
  const count = (args.path ? 1 : 0) + (args.paths?.length ?? 0);
46
- return `🛠 mkdir: ${count} director${count === 1 ? 'y' : 'ies'}`;
51
+ return `🛠 mkdir: ${count} directories`;
47
52
  },
48
53
  completionMessage: (args, result) => {
54
+ if (args.path && !args.paths?.length) {
55
+ const name = path.basename(args.path);
56
+ if (result.isError)
57
+ return `🛠 mkdir: ${name} • failed`;
58
+ return `🛠 mkdir: ${name} • created`;
59
+ }
49
60
  const count = (args.path ? 1 : 0) + (args.paths?.length ?? 0);
50
61
  if (result.isError)
51
- return `🛠 mkdir: ${count} • failed`;
52
- return `🛠 mkdir: ${count} • created`;
62
+ return `🛠 mkdir: ${count} directories • failed`;
63
+ return `🛠 mkdir: ${count} directories • created`;
53
64
  },
54
65
  });
55
66
  const validatedHandler = withValidatedArgs(CreateDirectoryInputSchema, wrappedHandler);
@@ -17,6 +17,7 @@ export const DELETE_FILE_TOOL = {
17
17
  'No undo — deletion is permanent.',
18
18
  'Non-empty directories require `recursive=true`.',
19
19
  ],
20
+ taskSupport: 'optional',
20
21
  };
21
22
  async function handleDeleteFile(args, signal) {
22
23
  const validPath = await validatePathForWrite(args.path, signal);
@@ -18,7 +18,22 @@ export const DIFF_FILES_TOOL = {
18
18
  outputSchema: DiffFilesOutputSchema,
19
19
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
20
20
  gotchas: ['`isIdentical=true` means no hunks (`@@`) and empty diff.'],
21
+ taskSupport: 'optional',
21
22
  };
23
+ function computeDiffStats(patch) {
24
+ let linesAdded = 0;
25
+ let linesRemoved = 0;
26
+ let hunksCount = 0;
27
+ for (const line of patch.split('\n')) {
28
+ if (line.startsWith('@@'))
29
+ hunksCount++;
30
+ else if (line.startsWith('+') && !line.startsWith('+++'))
31
+ linesAdded++;
32
+ else if (line.startsWith('-') && !line.startsWith('---'))
33
+ linesRemoved++;
34
+ }
35
+ return { linesAdded, linesRemoved, hunksCount };
36
+ }
22
37
  function assertDiffFileSizeWithinLimit(filePath, size, maxFileSize) {
23
38
  if (size <= maxFileSize)
24
39
  return;
@@ -47,6 +62,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
47
62
  });
48
63
  const isIdentical = !patch.includes('@@');
49
64
  const diffText = isIdentical ? '' : patch;
65
+ const stats = isIdentical ? undefined : computeDiffStats(patch);
50
66
  const externalized = maybeExternalizeTextContent(resourceStore, diffText, {
51
67
  name: 'diff:patch',
52
68
  mimeType: 'text/x-diff',
@@ -56,6 +72,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
56
72
  ok: true,
57
73
  diff: diffText,
58
74
  isIdentical,
75
+ ...(stats ?? {}),
59
76
  });
60
77
  }
61
78
  const { preview, entry } = externalized;
@@ -63,6 +80,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
63
80
  ok: true,
64
81
  diff: preview,
65
82
  isIdentical,
83
+ ...(stats ?? {}),
66
84
  truncated: true,
67
85
  resourceUri: entry.uri,
68
86
  }, [
@@ -71,6 +89,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
71
89
  name: entry.name,
72
90
  mimeType: entry.mimeType,
73
91
  description: 'Full diff content',
92
+ expiresAt: entry.expiresAt,
74
93
  }),
75
94
  ]);
76
95
  }
@@ -86,22 +105,21 @@ export function registerDiffFilesTool(server, options = {}) {
86
105
  const wrappedHandler = wrapToolHandler(handler, {
87
106
  guard: options.isInitialized,
88
107
  progressMessage: (args) => {
89
- const name1 = path.basename(args.original);
90
- const name2 = path.basename(args.modified);
91
- return `🕮 diff_files: ${name1} ⟷ ${name2}`;
108
+ const n1 = path.basename(args.original);
109
+ const n2 = path.basename(args.modified);
110
+ return `🕮 diff: ${n1} ⟷ ${n2}`;
92
111
  },
93
112
  completionMessage: (args, result) => {
94
113
  const n1 = path.basename(args.original);
95
114
  const n2 = path.basename(args.modified);
96
115
  if (result.isError)
97
- return `🕮 diff_files: ${n1} ⟷ ${n2} • failed`;
116
+ return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
98
117
  const sc = result.structuredContent;
99
118
  if (!sc.ok)
100
- return `🕮 diff_files: ${n1} ⟷ ${n2} • failed`;
119
+ return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
101
120
  if (sc.isIdentical)
102
- return `🕮 diff_files: ${n1} ⟷ ${n2} • identical`;
103
- const hunks = (sc.diff?.match(/@@/g) ?? []).length;
104
- return `🕮 diff_files: ${n1} ⟷ ${n2} • ${hunks} hunk${hunks !== 1 ? 's' : ''}`;
121
+ return `🕮 diff: ${n1} ⟷ ${n2} • identical`;
122
+ return `🕮 diff: ${n1} ⟷ ${n2} • changed`;
105
123
  },
106
124
  });
107
125
  const validatedHandler = withValidatedArgs(DiffFilesInputSchema, wrappedHandler);
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
+ import { createTwoFilesPatch } from 'diff';
3
4
  import RE2 from 're2';
4
5
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
6
  import { ErrorCode, McpError } from '../lib/errors.js';
@@ -19,6 +20,7 @@ export const EDIT_FILE_TOOL = {
19
20
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
20
21
  nuances: ['Each edit applies to the output of the previous edit.'],
21
22
  gotchas: ['Unmatched `oldText` entries listed in `unmatchedEdits`.'],
23
+ taskSupport: 'optional',
22
24
  };
23
25
  function escapeRegExp(string) {
24
26
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -99,6 +101,9 @@ export async function handleEditFile(args, signal) {
99
101
  ...(lineRange ? { lineRange } : {}),
100
102
  };
101
103
  if (args.dryRun) {
104
+ if (appliedEdits > 0) {
105
+ structured.diff = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
106
+ }
102
107
  return buildToolResponse(`Dry run complete. ${appliedEdits} edits would be applied.`, structured);
103
108
  }
104
109
  if (appliedEdits > 0) {
@@ -127,8 +132,9 @@ export function registerEditFileTool(server, options = {}) {
127
132
  guard: options.isInitialized,
128
133
  progressMessage: (args) => {
129
134
  const name = path.basename(args.path);
130
- const dryTag = args.dryRun ? ' [dry run]' : '';
131
- return `🛠 edit: ${name} [${args.edits.length} edits]${dryTag}`;
135
+ const count = args.edits.length;
136
+ const tag = args.dryRun ? ' [dry run]' : '';
137
+ return `🛠 edit: ${name} [${count} ${count === 1 ? 'edit' : 'edits'}]${tag}`;
132
138
  },
133
139
  completionMessage: (args, result) => {
134
140
  const name = path.basename(args.path);
@@ -139,14 +145,11 @@ export function registerEditFileTool(server, options = {}) {
139
145
  return `🛠 edit: ${name} • failed`;
140
146
  const applied = sc.appliedEdits ?? 0;
141
147
  const unmatched = sc.unmatchedEdits?.length ?? 0;
142
- const dryPrefix = args.dryRun ? 'dry run — ' : '';
148
+ const dry = args.dryRun ? 'dry run — ' : '';
143
149
  if (unmatched > 0) {
144
- return `🛠 edit: ${name} • ${dryPrefix}${applied} applied, ${unmatched} unmatched`;
145
- }
146
- if (sc.lineRange) {
147
- return `🛠 edit: ${name} • ${dryPrefix}lines ${sc.lineRange[0]}–${sc.lineRange[1]}`;
150
+ return `🛠 edit: ${name} • ${dry}${applied} applied, ${unmatched} unmatched`;
148
151
  }
149
- return `🛠 edit: ${name} • ${dryPrefix}${applied} applied`;
152
+ return `🛠 edit: ${name} • ${dry}${applied} applied`;
150
153
  },
151
154
  });
152
155
  const validatedHandler = withValidatedArgs(EditFileInputSchema, wrappedHandler);
@@ -112,12 +112,7 @@ export function registerListDirectoryTool(server, options = {}) {
112
112
  });
113
113
  const wrappedHandler = wrapToolHandler(handler, {
114
114
  guard: options.isInitialized,
115
- progressMessage: (args) => {
116
- if (args.path) {
117
- return `≣ ls: ${path.basename(args.path)}`;
118
- }
119
- return '≣ ls';
120
- },
115
+ progressMessage: (args) => `≣ ls: ${args.path ? path.basename(args.path) : '.'}`,
121
116
  completionMessage: (args, result) => {
122
117
  const base = args.path ? path.basename(args.path) : '.';
123
118
  if (result.isError)
@@ -17,6 +17,7 @@ export const MOVE_FILE_TOOL = {
17
17
  gotchas: [
18
18
  'On POSIX, an existing destination is silently overwritten; on Windows, rename fails with EEXIST if destination exists.',
19
19
  ],
20
+ taskSupport: 'optional',
20
21
  };
21
22
  export async function handleMoveFile(args, signal) {
22
23
  const sources = args.sources ?? (args.source ? [args.source] : []);
@@ -82,14 +83,36 @@ export async function handleMoveFile(args, signal) {
82
83
  // Cross-device link, fallback to copy + delete
83
84
  try {
84
85
  await withAbort(fs.cp(validSource, targetPath, { recursive: true }), signal);
85
- await withAbort(fs.rm(validSource, { recursive: true, force: true }), signal);
86
- movedSources.push(validSource);
87
86
  }
88
87
  catch (copyError) {
89
88
  failed.push({
90
89
  source: src,
91
90
  error: formatUnknownErrorMessage(copyError),
92
91
  });
92
+ continue;
93
+ }
94
+ // Copy succeeded — now remove source
95
+ try {
96
+ await withAbort(fs.rm(validSource, { recursive: true, force: true }), signal);
97
+ movedSources.push(validSource);
98
+ }
99
+ catch (deleteError) {
100
+ // Source delete failed after copy — rollback by removing the copy
101
+ try {
102
+ await fs.rm(targetPath, { recursive: true, force: true });
103
+ }
104
+ catch {
105
+ // Rollback failed — data exists in both locations
106
+ failed.push({
107
+ source: src,
108
+ error: `Cross-device move partially failed: data exists at both '${validSource}' and '${targetPath}'. ${formatUnknownErrorMessage(deleteError)}`,
109
+ });
110
+ continue;
111
+ }
112
+ failed.push({
113
+ source: src,
114
+ error: `Cross-device move failed: could not remove source. ${formatUnknownErrorMessage(deleteError)}`,
115
+ });
93
116
  }
94
117
  }
95
118
  else {
@@ -122,16 +145,25 @@ export function registerMoveFileTool(server, options = {}) {
122
145
  const wrappedHandler = wrapToolHandler(handler, {
123
146
  guard: options.isInitialized,
124
147
  progressMessage: (args) => {
125
- const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
126
148
  const dest = path.basename(args.destination);
127
- return `🛠 mv: ${count} item${count === 1 ? '' : 's'} → ${dest}`;
149
+ if (args.source && !args.sources?.length) {
150
+ return `🛠 mv: ${path.basename(args.source)} → ${dest}`;
151
+ }
152
+ const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
153
+ return `🛠 mv: ${count} items → ${dest}`;
128
154
  },
129
155
  completionMessage: (args, result) => {
130
- const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
131
156
  const dest = path.basename(args.destination);
157
+ if (args.source && !args.sources?.length) {
158
+ const src = path.basename(args.source);
159
+ if (result.isError)
160
+ return `🛠 mv: ${src} → ${dest} • failed`;
161
+ return `🛠 mv: ${src} → ${dest} • moved`;
162
+ }
163
+ const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
132
164
  if (result.isError)
133
- return `🛠 mv: ${count} → ${dest} • failed`;
134
- return `🛠 mv: ${count} → ${dest} • moved`;
165
+ return `🛠 mv: ${count} items → ${dest} • failed`;
166
+ return `🛠 mv: ${count} items → ${dest} • moved`;
135
167
  },
136
168
  });
137
169
  const validatedHandler = withValidatedArgs(MoveFileInputSchema, wrappedHandler);
@@ -16,7 +16,7 @@ export const READ_MULTIPLE_FILES_TOOL = {
16
16
  taskSupport: 'optional',
17
17
  nuances: ['Total read budget is capped by `MAX_READ_MANY_TOTAL_SIZE`.'],
18
18
  gotchas: [
19
- 'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
19
+ 'Per-file `truncationReason` can be `head`, `tail`, `range`, or `externalized`.',
20
20
  ],
21
21
  };
22
22
  function toStructuredReadManyResult(result) {
@@ -31,6 +31,8 @@ function toStructuredReadManyResult(result) {
31
31
  structured.resourceUri = result.resourceUri;
32
32
  if (result.head !== undefined)
33
33
  structured.head = result.head;
34
+ if (result.tail !== undefined)
35
+ structured.tail = result.tail;
34
36
  if (result.startLine !== undefined)
35
37
  structured.startLine = result.startLine;
36
38
  if (result.endLine !== undefined)
@@ -39,6 +41,8 @@ function toStructuredReadManyResult(result) {
39
41
  structured.hasMoreLines = result.hasMoreLines;
40
42
  if (result.totalLines !== undefined)
41
43
  structured.totalLines = result.totalLines;
44
+ if (result.linesRead !== undefined)
45
+ structured.linesRead = result.linesRead;
42
46
  if (result.truncationReason) {
43
47
  structured.truncationReason = result.truncationReason;
44
48
  }
@@ -50,6 +54,7 @@ async function handleReadMultipleFiles(args, signal, resourceStore, onReadComple
50
54
  const options = {
51
55
  ...(signal ? { signal } : {}),
52
56
  ...(args.head !== undefined ? { head: args.head } : {}),
57
+ ...(args.tail !== undefined ? { tail: args.tail } : {}),
53
58
  ...(args.startLine !== undefined ? { startLine: args.startLine } : {}),
54
59
  ...(args.endLine !== undefined ? { endLine: args.endLine } : {}),
55
60
  ...(onReadComplete ? { onReadComplete } : {}),
@@ -61,6 +66,9 @@ async function handleReadMultipleFiles(args, signal, resourceStore, onReadComple
61
66
  if (result.truncated && result.readMode === 'head') {
62
67
  baseTruncationReason = 'head';
63
68
  }
69
+ else if (result.truncated && result.readMode === 'tail') {
70
+ baseTruncationReason = 'tail';
71
+ }
64
72
  else if (result.truncated && result.readMode === 'range') {
65
73
  baseTruncationReason = 'range';
66
74
  }