@j0hanz/filesystem-mcp 1.11.1 → 1.12.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.
@@ -1,5 +1,5 @@
1
- import * as fs from 'node:fs/promises';
2
1
  import * as path from 'node:path';
2
+ import { readFile, stat } from 'node:fs/promises';
3
3
  import { applyPatch, parsePatch } from 'diff';
4
4
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
5
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
@@ -29,15 +29,6 @@ function assertPatchTargetSizeWithinLimit(filePath, size, maxFileSize) {
29
29
  return;
30
30
  throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for patch: ${filePath} (${size} bytes > ${maxFileSize} bytes).`, filePath, { size, maxFileSize });
31
31
  }
32
- function assertPatchHasHunks(patch) {
33
- if (!patch.trim()) {
34
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch content is empty.');
35
- }
36
- const hasHunk = /@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/u.test(patch);
37
- if (!hasHunk) {
38
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch must include unified hunk headers (e.g., @@ -1,2 +1,2 @@).');
39
- }
40
- }
41
32
  function countStructuredPatchStats(diff) {
42
33
  let linesAdded = 0;
43
34
  let linesRemoved = 0;
@@ -63,12 +54,12 @@ function extractPatchTargetPath(diff) {
63
54
  return stripGitPrefix(diff.oldFileName);
64
55
  return undefined;
65
56
  }
66
- async function applyPatchToFile(filePath, diff, options, signal) {
57
+ async function applyDiff(filePath, diff, options, signal) {
67
58
  const validPath = await validateExistingPath(filePath, signal);
68
59
  assertAllowedFileAccess(filePath, validPath);
69
- const stats = await withAbort(fs.stat(validPath), signal);
60
+ const stats = await withAbort(stat(validPath), signal);
70
61
  assertPatchTargetSizeWithinLimit(validPath, stats.size, MAX_TEXT_FILE_SIZE);
71
- const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
62
+ const content = await readFile(validPath, { encoding: 'utf-8', signal });
72
63
  const patched = applyPatch(content, diff, {
73
64
  fuzzFactor: options.fuzzFactor,
74
65
  autoConvertLineEndings: options.autoConvertLineEndings,
@@ -89,32 +80,31 @@ async function applyPatchToFile(filePath, diff, options, signal) {
89
80
  }
90
81
  return { path: validPath, applied: true, ...patchStats };
91
82
  }
92
- async function handleMultiFilePatch(basePath, parsed, options, signal) {
83
+ async function processMultiFilePatch(basePath, parsed, options, signal) {
93
84
  const validBase = await validateExistingPath(basePath, signal);
94
- const results = [];
95
- for (const diff of parsed) {
85
+ const promises = parsed.map(async (diff) => {
96
86
  const fileName = extractPatchTargetPath(diff);
97
87
  if (!fileName) {
98
- results.push({
88
+ return {
99
89
  path: '<unknown>',
100
90
  applied: false,
101
91
  error: 'Missing file name in patch header',
102
- });
103
- continue;
92
+ };
104
93
  }
105
94
  const filePath = path.resolve(validBase, fileName);
106
95
  try {
107
- const result = await applyPatchToFile(filePath, diff, options, signal);
108
- results.push({ ...result, path: fileName });
96
+ const result = await applyDiff(filePath, diff, options, signal);
97
+ return { ...result, path: fileName };
109
98
  }
110
99
  catch (error) {
111
- results.push({
100
+ return {
112
101
  path: fileName,
113
102
  applied: false,
114
103
  error: formatUnknownErrorMessage(error),
115
- });
104
+ };
116
105
  }
117
- }
106
+ });
107
+ const results = await Promise.all(promises);
118
108
  const totals = results.reduce((acc, r) => {
119
109
  if (r.applied) {
120
110
  acc.applied++;
@@ -137,24 +127,28 @@ async function handleMultiFilePatch(basePath, parsed, options, signal) {
137
127
  });
138
128
  }
139
129
  async function handleApplyPatch(args, signal) {
140
- assertPatchHasHunks(args.patch);
130
+ if (!args.patch.trim()) {
131
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch content is empty.');
132
+ }
141
133
  const fuzzFactor = args.fuzzFactor ?? 0;
142
134
  const parsed = parsePatch(args.patch);
135
+ const hasHunks = parsed.some((p) => p.hunks.length > 0);
136
+ if (!hasHunks) {
137
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch must include unified hunk headers (e.g., @@ -1,2 +1,2 @@).');
138
+ }
143
139
  const options = {
144
140
  dryRun: args.dryRun,
145
141
  fuzzFactor,
146
142
  autoConvertLineEndings: args.autoConvertLineEndings,
147
143
  };
148
- // Multi-file patch: best-effort per file
149
144
  if (parsed.length > 1) {
150
- return handleMultiFilePatch(args.path, parsed, options, signal);
145
+ return processMultiFilePatch(args.path, parsed, options, signal);
151
146
  }
152
- // Single-file patch: delegate to shared helper, then assert success
153
147
  const diff = parsed[0];
154
148
  if (!diff) {
155
149
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'No patch content found.');
156
150
  }
157
- const result = await applyPatchToFile(args.path, diff, options, signal);
151
+ const result = await applyDiff(args.path, diff, options, signal);
158
152
  if (!result.applied) {
159
153
  throw new McpError(ErrorCode.E_INVALID_INPUT, result.error === 'Patch had no effect'
160
154
  ? '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.'
@@ -196,7 +190,7 @@ export function registerApplyPatchTool(server, options = {}) {
196
190
  return `🛠 patch: ${name} • failed`;
197
191
  const added = sc.linesAdded ?? 0;
198
192
  const removed = sc.linesRemoved ?? 0;
199
- const dry = args.dryRun ? 'dry run — ' : '';
193
+ const dry = args.dryRun ? 'dry run ' : '';
200
194
  if (added > 0 || removed > 0)
201
195
  return `🛠 patch: ${name} • ${dry} +${added} -${removed}`;
202
196
  return `🛠 patch: ${name} • ${dry}no changes`;
@@ -2,6 +2,7 @@ import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { createReadStream } from 'node:fs';
5
+ import { pipeline } from 'node:stream/promises';
5
6
  import { PARALLEL_CONCURRENCY } from '../lib/constants.js';
6
7
  import { ErrorCode } from '../lib/errors.js';
7
8
  import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/core.js';
@@ -26,15 +27,9 @@ export const CALCULATE_HASH_TOOL = {
26
27
  taskSupport: 'optional',
27
28
  };
28
29
  async function hashFile(filePath, encoding, signal) {
29
- assertNotAborted(signal);
30
- const hashOp = createHash('sha256');
31
- const stream = createReadStream(filePath, { signal });
32
- for await (const chunk of stream) {
33
- hashOp.update(chunk);
34
- assertNotAborted(signal);
35
- }
36
- assertNotAborted(signal);
37
- return encoding === undefined ? hashOp.digest() : hashOp.digest(encoding);
30
+ const hasher = createHash('sha256');
31
+ await pipeline(createReadStream(filePath, { signal, highWaterMark: 64 * 1024 }), hasher, { signal });
32
+ return encoding ? hasher.digest(encoding) : hasher.digest();
38
33
  }
39
34
  function toStableRelativePath(root, entryPath) {
40
35
  const relativePath = path.relative(root, entryPath);
@@ -84,25 +79,27 @@ async function hashDirectory(dirPath, options = {}) {
84
79
  });
85
80
  }
86
81
  assertNotAborted(signal);
87
- // Phase 2: hash files concurrently with bounded pool.
88
82
  const concurrency = Math.min(PARALLEL_CONCURRENCY, 8);
89
83
  const entries = [];
90
84
  let filesHashed = 0;
91
85
  const totalFiles = filteredPaths.length;
92
- for (let i = 0; i < filteredPaths.length; i += concurrency) {
93
- assertNotAborted(signal);
94
- const batch = filteredPaths.slice(i, i + concurrency);
95
- const batchResults = await Promise.all(batch.map(async ({ filePath, relativePath }) => {
96
- const fileHash = await hashFile(filePath, undefined, signal);
97
- return { path: relativePath, hash: fileHash };
98
- }));
99
- entries.push(...batchResults);
100
- filesHashed += batchResults.length;
101
- reportPeriodicProgress(onProgress, filesHashed, {
102
- throttleModulo: 25,
103
- total: totalFiles,
104
- });
105
- }
86
+ const taskQueue = filteredPaths;
87
+ const worker = async () => {
88
+ while (taskQueue.length > 0) {
89
+ const task = taskQueue.pop();
90
+ if (!task)
91
+ break;
92
+ assertNotAborted(signal);
93
+ const fileHash = await hashFile(task.filePath, undefined, signal);
94
+ entries.push({ path: task.relativePath, hash: fileHash });
95
+ filesHashed++;
96
+ reportPeriodicProgress(onProgress, filesHashed, {
97
+ throttleModulo: 25,
98
+ total: totalFiles,
99
+ });
100
+ }
101
+ };
102
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
106
103
  reportPeriodicProgress(onProgress, filesHashed, {
107
104
  throttleModulo: 25,
108
105
  total: totalFiles,
@@ -1,6 +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
+ import { formatPatch, structuredPatch } from 'diff';
4
4
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
5
  import { ErrorCode, McpError } from '../lib/errors.js';
6
6
  import { withAbort } from '../lib/fs-helpers.js';
@@ -20,19 +20,18 @@ export const DIFF_FILES_TOOL = {
20
20
  gotchas: ['`isIdentical=true` means no hunks (`@@`) and empty diff.'],
21
21
  taskSupport: 'forbidden',
22
22
  };
23
- function computeDiffStats(patch) {
23
+ function computeDiffStats(hunks) {
24
24
  let linesAdded = 0;
25
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++;
26
+ for (const hunk of hunks) {
27
+ for (const line of hunk.lines) {
28
+ if (line.startsWith('+') && !line.startsWith('+++'))
29
+ linesAdded++;
30
+ else if (line.startsWith('-') && !line.startsWith('---'))
31
+ linesRemoved++;
32
+ }
34
33
  }
35
- return { linesAdded, linesRemoved, hunksCount };
34
+ return { linesAdded, linesRemoved, hunksCount: hunks.length };
36
35
  }
37
36
  function assertDiffFileSizeWithinLimit(filePath, size, maxFileSize) {
38
37
  if (size <= maxFileSize)
@@ -55,14 +54,23 @@ async function handleDiffFiles(args, signal, resourceStore) {
55
54
  fs.readFile(originalPath, { encoding: 'utf-8', signal }),
56
55
  fs.readFile(modifiedPath, { encoding: 'utf-8', signal }),
57
56
  ]);
58
- const patch = createTwoFilesPatch(path.basename(originalPath), path.basename(modifiedPath), originalContent, modifiedContent, undefined, undefined, {
59
- ...(args.context !== undefined ? { context: args.context } : {}),
60
- ignoreWhitespace: args.ignoreWhitespace,
61
- stripTrailingCr: args.stripTrailingCr,
57
+ const patchObj = await new Promise((resolve) => {
58
+ structuredPatch(path.basename(originalPath), path.basename(modifiedPath), originalContent, modifiedContent, undefined, undefined, {
59
+ ...(args.context !== undefined ? { context: args.context } : {}),
60
+ ignoreWhitespace: args.ignoreWhitespace,
61
+ stripTrailingCr: args.stripTrailingCr,
62
+ timeout: 10000,
63
+ callback: (res) => {
64
+ resolve(res);
65
+ },
66
+ });
62
67
  });
63
- const isIdentical = !patch.includes('@@');
64
- const diffText = isIdentical ? '' : patch;
65
- const stats = isIdentical ? undefined : computeDiffStats(patch);
68
+ if (!patchObj) {
69
+ throw new McpError(ErrorCode.E_TIMEOUT, `Diff computation timed out or failed due to complexity.`, originalPath);
70
+ }
71
+ const isIdentical = patchObj.hunks.length === 0;
72
+ const diffText = isIdentical ? '' : formatPatch(patchObj);
73
+ const stats = isIdentical ? undefined : computeDiffStats(patchObj.hunks);
66
74
  const externalized = maybeExternalizeTextContent(resourceStore, diffText, {
67
75
  name: 'diff:patch',
68
76
  mimeType: 'text/x-diff',
@@ -3,5 +3,8 @@ import type { z } from 'zod';
3
3
  import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
4
4
  import { type ToolContract, type ToolRegistrationOptions, type ToolResponse } from './shared.js';
5
5
  export declare const EDIT_FILE_TOOL: ToolContract;
6
- export declare function handleEditFile(args: z.infer<typeof EditFileInputSchema>, signal?: AbortSignal): Promise<ToolResponse<z.infer<typeof EditFileOutputSchema>>>;
6
+ type EditInput = z.infer<typeof EditFileInputSchema>;
7
+ type EditOutput = z.infer<typeof EditFileOutputSchema>;
8
+ export declare function handleEditFile(args: EditInput, signal?: AbortSignal): Promise<ToolResponse<EditOutput>>;
7
9
  export declare function registerEditFileTool(server: McpServer, options?: ToolRegistrationOptions): void;
10
+ export {};
@@ -1,6 +1,6 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
3
- import { createTwoFilesPatch } from 'diff';
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { createTwoFilesPatch, diffLines } from 'diff';
4
4
  import RE2 from 're2';
5
5
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
6
6
  import { ErrorCode, McpError } from '../lib/errors.js';
@@ -13,7 +13,7 @@ export const EDIT_FILE_TOOL = {
13
13
  name: 'edit',
14
14
  title: 'Edit File',
15
15
  description: 'Apply sequential literal string replacements to a file (first occurrence per edit). ' +
16
- '`oldText` must match exactly \u2014 include 3\u20135 lines of context for unique targeting. ' +
16
+ '`oldText` must match exactly — include 3–5 lines of context for unique targeting. ' +
17
17
  'Use `dryRun:true` to preview.',
18
18
  inputSchema: EditFileInputSchema,
19
19
  outputSchema: EditFileOutputSchema,
@@ -25,117 +25,186 @@ export const EDIT_FILE_TOOL = {
25
25
  function escapeRegExp(string) {
26
26
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
27
  }
28
+ function getLineNumberAtIndex(str, maxIndex = str.length) {
29
+ let count = 1;
30
+ let pos = 0;
31
+ while (pos < maxIndex) {
32
+ pos = str.indexOf('\n', pos);
33
+ if (pos === -1 || pos >= maxIndex)
34
+ break;
35
+ count++;
36
+ pos++;
37
+ }
38
+ return count;
39
+ }
40
+ function countLines(str) {
41
+ return getLineNumberAtIndex(str);
42
+ }
28
43
  function computeDiffStats(original, modified) {
29
- const patch = createTwoFilesPatch('a', 'b', original, modified);
44
+ const changes = diffLines(original, modified);
30
45
  let linesAdded = 0;
31
46
  let linesRemoved = 0;
32
- for (const line of patch.split('\n')) {
33
- if (line.startsWith('+') && !line.startsWith('+++'))
34
- linesAdded++;
35
- else if (line.startsWith('-') && !line.startsWith('---'))
36
- linesRemoved++;
47
+ for (const part of changes) {
48
+ if (part.added) {
49
+ linesAdded += part.count;
50
+ }
51
+ else if (part.removed) {
52
+ linesRemoved += part.count;
53
+ }
37
54
  }
38
55
  return { linesAdded, linesRemoved };
39
56
  }
40
- function applyEdits(content, edits, ignoreWhitespace) {
41
- let newContent = content;
42
- let appliedEdits = 0;
43
- const unmatchedEdits = [];
44
- let minLine;
45
- let maxLine;
46
- for (const edit of edits) {
47
- if (ignoreWhitespace) {
48
- const pattern = escapeRegExp(edit.oldText).replace(/\s+/g, '\\s+');
49
- const regex = new RE2(pattern);
50
- const match = regex.exec(newContent);
51
- if (!match) {
52
- unmatchedEdits.push(edit.oldText);
53
- continue;
54
- }
55
- const { index } = match;
56
- const matchLength = match[0].length;
57
- const linesBefore = newContent.slice(0, index).split('\n').length;
58
- const newTextLines = edit.newText.split('\n').length;
59
- const startLine = linesBefore;
60
- const endLine = linesBefore + newTextLines - 1;
61
- if (minLine === undefined || startLine < minLine)
62
- minLine = startLine;
63
- if (maxLine === undefined || endLine > maxLine)
64
- maxLine = endLine;
65
- newContent =
66
- newContent.slice(0, index) +
67
- edit.newText +
68
- newContent.slice(index + matchLength);
69
- appliedEdits += 1;
70
- }
71
- else {
72
- if (!newContent.includes(edit.oldText)) {
73
- unmatchedEdits.push(edit.oldText);
74
- continue;
75
- }
76
- const index = newContent.indexOf(edit.oldText);
77
- const linesBefore = newContent.slice(0, index).split('\n').length;
78
- const newTextLines = edit.newText.split('\n').length;
79
- const startLine = linesBefore;
80
- const endLine = linesBefore + newTextLines - 1;
81
- if (minLine === undefined || startLine < minLine)
82
- minLine = startLine;
83
- if (maxLine === undefined || endLine > maxLine)
84
- maxLine = endLine;
85
- newContent = newContent.replace(edit.oldText, () => edit.newText);
86
- appliedEdits += 1;
57
+ function findEditMatch(content, oldText, ignoreWhitespace) {
58
+ if (ignoreWhitespace) {
59
+ const pattern = escapeRegExp(oldText).replace(/\s+/g, '\\s+');
60
+ const regex = new RE2(pattern);
61
+ const match = regex.exec(content);
62
+ if (!match) {
63
+ return undefined;
87
64
  }
65
+ return {
66
+ startIndex: match.index,
67
+ length: match[0].length,
68
+ };
69
+ }
70
+ const index = content.indexOf(oldText);
71
+ if (index === -1) {
72
+ return undefined;
73
+ }
74
+ return {
75
+ startIndex: index,
76
+ length: oldText.length,
77
+ };
78
+ }
79
+ function replaceEditMatch(content, match, newText) {
80
+ return (content.slice(0, match.startIndex) +
81
+ newText +
82
+ content.slice(match.startIndex + match.length));
83
+ }
84
+ function mergeLineRange(currentRange, content, matchStartIndex, newText) {
85
+ const startLine = getLineNumberAtIndex(content, matchStartIndex);
86
+ const endLine = startLine + countLines(newText) - 1;
87
+ if (!currentRange) {
88
+ return [startLine, endLine];
88
89
  }
90
+ return [
91
+ Math.min(currentRange[0], startLine),
92
+ Math.max(currentRange[1], endLine),
93
+ ];
94
+ }
95
+ function buildStructuredEditOutput(validPath, result) {
96
+ return {
97
+ ok: true,
98
+ path: validPath,
99
+ appliedEdits: result.appliedEdits,
100
+ ...(result.appliedEdits > 0
101
+ ? {
102
+ linesAdded: result.linesAdded,
103
+ linesRemoved: result.linesRemoved,
104
+ }
105
+ : {}),
106
+ ...(result.unmatchedEdits.length > 0
107
+ ? { unmatchedEdits: result.unmatchedEdits }
108
+ : {}),
109
+ ...(result.lineRange ? { lineRange: result.lineRange } : {}),
110
+ };
111
+ }
112
+ function finalizeEditResult(originalContent, updatedContent, appliedEdits, unmatchedEdits, lineRange) {
89
113
  const { linesAdded, linesRemoved } = appliedEdits > 0
90
- ? computeDiffStats(content, newContent)
114
+ ? computeDiffStats(originalContent, updatedContent)
91
115
  : { linesAdded: 0, linesRemoved: 0 };
92
- const result = {
93
- content: newContent,
116
+ return {
117
+ content: updatedContent,
94
118
  appliedEdits,
95
119
  unmatchedEdits,
96
120
  linesAdded,
97
121
  linesRemoved,
122
+ ...(lineRange ? { lineRange } : {}),
98
123
  };
99
- if (minLine !== undefined && maxLine !== undefined) {
100
- result.lineRange = [minLine, maxLine];
124
+ }
125
+ function buildDiff(validPath, original, modified) {
126
+ const fileName = basename(validPath);
127
+ return createTwoFilesPatch(fileName, fileName, original, modified, 'Original', 'Modified');
128
+ }
129
+ function formatUnmatchedEditsNote(unmatchedEdits) {
130
+ if (unmatchedEdits.length === 0) {
131
+ return '';
101
132
  }
102
- return result;
133
+ return ` — ${unmatchedEdits.length} unmatched: [${unmatchedEdits
134
+ .map((text) => JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}…` : text))
135
+ .join(', ')}]`;
103
136
  }
104
- export async function handleEditFile(args, signal) {
105
- const validPath = await validateExistingPath(args.path, signal);
106
- assertAllowedFileAccess(args.path, validPath);
107
- const stats = await withAbort(fs.stat(validPath), signal);
137
+ function buildEditMessage(requestedPath, result) {
138
+ const unmatchedNote = formatUnmatchedEditsNote(result.unmatchedEdits);
139
+ if (result.appliedEdits === 0) {
140
+ return `No edits applied to ${requestedPath}${unmatchedNote}`;
141
+ }
142
+ return `Successfully applied ${result.appliedEdits} edits to ${requestedPath}${unmatchedNote}`;
143
+ }
144
+ async function loadEditableFile(requestedPath, signal) {
145
+ const validPath = await validateExistingPath(requestedPath, signal);
146
+ assertAllowedFileAccess(requestedPath, validPath);
147
+ const stats = await withAbort(stat(validPath), signal);
108
148
  if (stats.size > MAX_TEXT_FILE_SIZE) {
109
- throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for edit: ${args.path} (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, args.path, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
149
+ 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 });
110
150
  }
111
- const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
112
- const { content: newContent, appliedEdits, unmatchedEdits, linesAdded, linesRemoved, lineRange, } = applyEdits(content, args.edits, args.ignoreWhitespace);
113
- const structured = {
114
- ok: true,
115
- path: validPath,
116
- appliedEdits,
117
- ...(appliedEdits > 0 ? { linesAdded, linesRemoved } : {}),
118
- ...(unmatchedEdits.length > 0 ? { unmatchedEdits } : {}),
119
- ...(lineRange ? { lineRange } : {}),
120
- };
151
+ const content = await readFile(validPath, { encoding: 'utf-8', signal });
152
+ return { validPath, content };
153
+ }
154
+ function buildEditProgressMessage(args) {
155
+ const name = basename(args.path);
156
+ const tag = args.dryRun ? ' [dry run]' : '';
157
+ return `🛠 edit: ${name}${tag}`;
158
+ }
159
+ function buildEditCompletionMessage(args, result) {
160
+ const name = basename(args.path);
161
+ if (result.isError)
162
+ return `🛠 edit: ${name} • failed`;
163
+ const { structuredContent } = result;
164
+ if (!structuredContent.ok)
165
+ return `🛠 edit: ${name} • failed`;
166
+ const applied = structuredContent.appliedEdits ?? 0;
167
+ if (applied === 0)
168
+ return `🛠 edit: ${name} • no changes`;
169
+ const added = structuredContent.linesAdded ?? 0;
170
+ const removed = structuredContent.linesRemoved ?? 0;
171
+ const dry = args.dryRun ? 'dry run ' : '';
172
+ return `🛠 edit: ${name} • ${dry}+${added} -${removed}`;
173
+ }
174
+ function applyEdits(content, edits, ignoreWhitespace) {
175
+ let newContent = content;
176
+ let appliedEdits = 0;
177
+ const unmatchedEdits = [];
178
+ let lineRange;
179
+ for (const edit of edits) {
180
+ const match = findEditMatch(newContent, edit.oldText, ignoreWhitespace);
181
+ if (!match) {
182
+ unmatchedEdits.push(edit.oldText);
183
+ continue;
184
+ }
185
+ lineRange = mergeLineRange(lineRange, newContent, match.startIndex, edit.newText);
186
+ newContent = replaceEditMatch(newContent, match, edit.newText);
187
+ appliedEdits += 1;
188
+ }
189
+ return finalizeEditResult(content, newContent, appliedEdits, unmatchedEdits, lineRange);
190
+ }
191
+ export async function handleEditFile(args, signal) {
192
+ const { validPath, content } = await loadEditableFile(args.path, signal);
193
+ const editResult = applyEdits(content, args.edits, args.ignoreWhitespace);
194
+ const structured = buildStructuredEditOutput(validPath, editResult);
121
195
  if (args.dryRun) {
122
- if (appliedEdits > 0) {
123
- structured.diff = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
196
+ if (editResult.appliedEdits > 0) {
197
+ structured.diff = buildDiff(validPath, content, editResult.content);
124
198
  }
125
- return buildToolResponse(`Dry run complete. ${appliedEdits} edits would be applied.`, structured);
199
+ return buildToolResponse(`Dry run complete. ${editResult.appliedEdits} edits would be applied.`, structured);
126
200
  }
127
- if (appliedEdits > 0) {
128
- await atomicWriteFile(validPath, newContent, { encoding: 'utf-8', signal });
201
+ if (editResult.appliedEdits > 0) {
202
+ await atomicWriteFile(validPath, editResult.content, {
203
+ encoding: 'utf-8',
204
+ signal,
205
+ });
129
206
  }
130
- const unmatchedNote = unmatchedEdits.length > 0
131
- ? ` — ${unmatchedEdits.length} unmatched: [${unmatchedEdits
132
- .map((s) => JSON.stringify(s.length > 40 ? `${s.slice(0, 40)}\u2026` : s))
133
- .join(', ')}]`
134
- : '';
135
- const message = appliedEdits === 0
136
- ? `No edits applied to ${args.path}${unmatchedNote}`
137
- : `Successfully applied ${appliedEdits} edits to ${args.path}${unmatchedNote}`;
138
- return buildToolResponse(message, structured);
207
+ return buildToolResponse(buildEditMessage(args.path, editResult), structured);
139
208
  }
140
209
  export function registerEditFileTool(server, options = {}) {
141
210
  const handler = (args, extra) => executeToolWithDiagnostics({
@@ -148,26 +217,8 @@ export function registerEditFileTool(server, options = {}) {
148
217
  });
149
218
  const wrappedHandler = wrapToolHandler(handler, {
150
219
  guard: options.isInitialized,
151
- progressMessage: (args) => {
152
- const name = path.basename(args.path);
153
- const tag = args.dryRun ? ' [dry run]' : '';
154
- return `🛠 edit: ${name}${tag}`;
155
- },
156
- completionMessage: (args, result) => {
157
- const name = path.basename(args.path);
158
- if (result.isError)
159
- return `🛠 edit: ${name} • failed`;
160
- const sc = result.structuredContent;
161
- if (!sc.ok)
162
- return `🛠 edit: ${name} • failed`;
163
- const applied = sc.appliedEdits ?? 0;
164
- if (applied === 0)
165
- return `🛠 edit: ${name} • no changes`;
166
- const added = sc.linesAdded ?? 0;
167
- const removed = sc.linesRemoved ?? 0;
168
- const dry = args.dryRun ? 'dry run — ' : '';
169
- return `🛠 edit: ${name} • ${dry} +${added} -${removed}`;
170
- },
220
+ progressMessage: buildEditProgressMessage,
221
+ completionMessage: buildEditCompletionMessage,
171
222
  });
172
223
  const validatedHandler = withValidatedArgs(EditFileInputSchema, wrappedHandler);
173
224
  if (registerToolTaskIfAvailable(server, 'edit', EDIT_FILE_TOOL, validatedHandler, options.iconInfo, options.isInitialized))