@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.
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/file-operations/metadata.js +71 -142
- package/dist/lib/file-operations/search.js +119 -19
- package/dist/lib/file-operations/traversal.js +45 -102
- package/dist/lib/fs-helpers.js +175 -158
- package/dist/resources/tool-info.js +23 -19
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +8 -4
- package/dist/server/bootstrap.js +39 -4
- package/dist/tools/apply-patch.js +24 -30
- package/dist/tools/calculate-hash.js +21 -24
- package/dist/tools/diff-files.js +26 -18
- package/dist/tools/edit-file.d.ts +4 -1
- package/dist/tools/edit-file.js +163 -112
- package/dist/tools/list-directory.js +105 -10
- package/dist/tools/read-multiple.d.ts +1 -1
- package/dist/tools/read-multiple.js +101 -65
- package/dist/tools/read.js +103 -75
- package/dist/tools/replace-in-files.js +85 -38
- package/dist/tools/search-content.js +245 -216
- package/dist/tools/shared.js +35 -4
- package/dist/tools/task-support.d.ts +7 -0
- package/dist/tools/task-support.js +137 -100
- package/dist/tools.js +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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(
|
|
60
|
+
const stats = await withAbort(stat(validPath), signal);
|
|
70
61
|
assertPatchTargetSizeWithinLimit(validPath, stats.size, MAX_TEXT_FILE_SIZE);
|
|
71
|
-
const content = await
|
|
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
|
|
83
|
+
async function processMultiFilePatch(basePath, parsed, options, signal) {
|
|
93
84
|
const validBase = await validateExistingPath(basePath, signal);
|
|
94
|
-
const
|
|
95
|
-
for (const diff of parsed) {
|
|
85
|
+
const promises = parsed.map(async (diff) => {
|
|
96
86
|
const fileName = extractPatchTargetPath(diff);
|
|
97
87
|
if (!fileName) {
|
|
98
|
-
|
|
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
|
|
108
|
-
|
|
96
|
+
const result = await applyDiff(filePath, diff, options, signal);
|
|
97
|
+
return { ...result, path: fileName };
|
|
109
98
|
}
|
|
110
99
|
catch (error) {
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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,
|
package/dist/tools/diff-files.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import {
|
|
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(
|
|
23
|
+
function computeDiffStats(hunks) {
|
|
24
24
|
let linesAdded = 0;
|
|
25
25
|
let linesRemoved = 0;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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 {};
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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
|
|
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
|
|
44
|
+
const changes = diffLines(original, modified);
|
|
30
45
|
let linesAdded = 0;
|
|
31
46
|
let linesRemoved = 0;
|
|
32
|
-
for (const
|
|
33
|
-
if (
|
|
34
|
-
linesAdded
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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(
|
|
114
|
+
? computeDiffStats(originalContent, updatedContent)
|
|
91
115
|
: { linesAdded: 0, linesRemoved: 0 };
|
|
92
|
-
|
|
93
|
-
content:
|
|
116
|
+
return {
|
|
117
|
+
content: updatedContent,
|
|
94
118
|
appliedEdits,
|
|
95
119
|
unmatchedEdits,
|
|
96
120
|
linesAdded,
|
|
97
121
|
linesRemoved,
|
|
122
|
+
...(lineRange ? { lineRange } : {}),
|
|
98
123
|
};
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
133
|
+
return ` — ${unmatchedEdits.length} unmatched: [${unmatchedEdits
|
|
134
|
+
.map((text) => JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}…` : text))
|
|
135
|
+
.join(', ')}]`;
|
|
103
136
|
}
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
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: ${
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 =
|
|
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,
|
|
201
|
+
if (editResult.appliedEdits > 0) {
|
|
202
|
+
await atomicWriteFile(validPath, editResult.content, {
|
|
203
|
+
encoding: 'utf-8',
|
|
204
|
+
signal,
|
|
205
|
+
});
|
|
129
206
|
}
|
|
130
|
-
|
|
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:
|
|
152
|
-
|
|
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))
|