@j0hanz/filesystem-mcp 1.11.0 → 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/completions.js +25 -0
- 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/prompts.d.ts +1 -0
- package/dist/prompts.js +58 -0
- package/dist/resources/generated-instructions.js +11 -3
- package/dist/resources/tool-catalog.js +49 -3
- package/dist/resources/tool-info.d.ts +1 -0
- package/dist/resources/tool-info.js +139 -8
- package/dist/resources.js +2 -0
- package/dist/schemas.d.ts +2 -0
- package/dist/schemas.js +10 -4
- package/dist/server/bootstrap.js +41 -5
- package/dist/tools/apply-patch.js +30 -33
- package/dist/tools/calculate-hash.js +21 -24
- package/dist/tools/contract.d.ts +1 -1
- package/dist/tools/create-directory.js +3 -3
- package/dist/tools/delete-file.js +2 -2
- package/dist/tools/diff-files.js +32 -20
- package/dist/tools/edit-file.d.ts +4 -1
- package/dist/tools/edit-file.js +172 -104
- package/dist/tools/list-directory.js +105 -10
- package/dist/tools/move-file.js +3 -3
- package/dist/tools/read-multiple.d.ts +1 -1
- package/dist/tools/read-multiple.js +123 -99
- package/dist/tools/read.js +104 -76
- package/dist/tools/replace-in-files.d.ts +5 -2
- package/dist/tools/replace-in-files.js +142 -76
- package/dist/tools/roots.js +1 -1
- package/dist/tools/search-content.js +249 -218
- package/dist/tools/shared.js +39 -7
- package/dist/tools/stat-many.js +12 -19
- package/dist/tools/stat.js +1 -1
- package/dist/tools/task-support.d.ts +7 -0
- package/dist/tools/task-support.js +140 -101
- package/dist/tools/write-file.js +4 -4
- package/dist/tools.js +2 -2
- package/package.json +1 -1
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';
|
|
@@ -18,21 +18,20 @@ 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: '
|
|
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',
|
|
@@ -119,7 +127,11 @@ export function registerDiffFilesTool(server, options = {}) {
|
|
|
119
127
|
return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
|
|
120
128
|
if (sc.isIdentical)
|
|
121
129
|
return `🕮 diff: ${n1} ⟷ ${n2} • identical`;
|
|
122
|
-
|
|
130
|
+
const added = sc.linesAdded ?? 0;
|
|
131
|
+
const removed = sc.linesRemoved ?? 0;
|
|
132
|
+
if (added > 0 || removed > 0)
|
|
133
|
+
return `🕮 diff: ${n1} ⟷ ${n2} • +${added} -${removed}`;
|
|
134
|
+
return `🕮 diff: ${n1} ⟷ ${n2}`;
|
|
123
135
|
},
|
|
124
136
|
});
|
|
125
137
|
const validatedHandler = withValidatedArgs(DiffFilesInputSchema, wrappedHandler);
|
|
@@ -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,111 +13,198 @@ 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,
|
|
20
20
|
annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
|
|
21
21
|
nuances: ['Each edit applies to the output of the previous edit.'],
|
|
22
22
|
gotchas: ['Unmatched `oldText` entries listed in `unmatchedEdits`.'],
|
|
23
|
-
taskSupport: '
|
|
23
|
+
taskSupport: 'forbidden',
|
|
24
24
|
};
|
|
25
25
|
function escapeRegExp(string) {
|
|
26
26
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
27
27
|
}
|
|
28
|
-
function
|
|
29
|
-
let
|
|
30
|
-
let
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
minLine = startLine;
|
|
51
|
-
if (maxLine === undefined || endLine > maxLine)
|
|
52
|
-
maxLine = endLine;
|
|
53
|
-
newContent =
|
|
54
|
-
newContent.slice(0, index) +
|
|
55
|
-
edit.newText +
|
|
56
|
-
newContent.slice(index + matchLength);
|
|
57
|
-
appliedEdits += 1;
|
|
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
|
+
}
|
|
43
|
+
function computeDiffStats(original, modified) {
|
|
44
|
+
const changes = diffLines(original, modified);
|
|
45
|
+
let linesAdded = 0;
|
|
46
|
+
let linesRemoved = 0;
|
|
47
|
+
for (const part of changes) {
|
|
48
|
+
if (part.added) {
|
|
49
|
+
linesAdded += part.count;
|
|
58
50
|
}
|
|
59
|
-
else {
|
|
60
|
-
|
|
61
|
-
unmatchedEdits.push(edit.oldText);
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const index = newContent.indexOf(edit.oldText);
|
|
65
|
-
const linesBefore = newContent.slice(0, index).split('\n').length;
|
|
66
|
-
const newTextLines = edit.newText.split('\n').length;
|
|
67
|
-
const startLine = linesBefore;
|
|
68
|
-
const endLine = linesBefore + newTextLines - 1;
|
|
69
|
-
if (minLine === undefined || startLine < minLine)
|
|
70
|
-
minLine = startLine;
|
|
71
|
-
if (maxLine === undefined || endLine > maxLine)
|
|
72
|
-
maxLine = endLine;
|
|
73
|
-
newContent = newContent.replace(edit.oldText, () => edit.newText);
|
|
74
|
-
appliedEdits += 1;
|
|
51
|
+
else if (part.removed) {
|
|
52
|
+
linesRemoved += part.count;
|
|
75
53
|
}
|
|
76
54
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
55
|
+
return { linesAdded, linesRemoved };
|
|
56
|
+
}
|
|
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;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
startIndex: match.index,
|
|
67
|
+
length: match[0].length,
|
|
68
|
+
};
|
|
84
69
|
}
|
|
85
|
-
|
|
70
|
+
const index = content.indexOf(oldText);
|
|
71
|
+
if (index === -1) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
startIndex: index,
|
|
76
|
+
length: oldText.length,
|
|
77
|
+
};
|
|
86
78
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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];
|
|
93
89
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
return [
|
|
91
|
+
Math.min(currentRange[0], startLine),
|
|
92
|
+
Math.max(currentRange[1], endLine),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
function buildStructuredEditOutput(validPath, result) {
|
|
96
|
+
return {
|
|
97
97
|
ok: true,
|
|
98
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) {
|
|
113
|
+
const { linesAdded, linesRemoved } = appliedEdits > 0
|
|
114
|
+
? computeDiffStats(originalContent, updatedContent)
|
|
115
|
+
: { linesAdded: 0, linesRemoved: 0 };
|
|
116
|
+
return {
|
|
117
|
+
content: updatedContent,
|
|
99
118
|
appliedEdits,
|
|
100
|
-
|
|
119
|
+
unmatchedEdits,
|
|
120
|
+
linesAdded,
|
|
121
|
+
linesRemoved,
|
|
101
122
|
...(lineRange ? { lineRange } : {}),
|
|
102
123
|
};
|
|
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 '';
|
|
132
|
+
}
|
|
133
|
+
return ` — ${unmatchedEdits.length} unmatched: [${unmatchedEdits
|
|
134
|
+
.map((text) => JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}…` : text))
|
|
135
|
+
.join(', ')}]`;
|
|
136
|
+
}
|
|
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);
|
|
148
|
+
if (stats.size > 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 });
|
|
150
|
+
}
|
|
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);
|
|
103
195
|
if (args.dryRun) {
|
|
104
|
-
if (appliedEdits > 0) {
|
|
105
|
-
structured.diff =
|
|
196
|
+
if (editResult.appliedEdits > 0) {
|
|
197
|
+
structured.diff = buildDiff(validPath, content, editResult.content);
|
|
106
198
|
}
|
|
107
|
-
return buildToolResponse(`Dry run complete. ${appliedEdits} edits would be applied.`, structured);
|
|
199
|
+
return buildToolResponse(`Dry run complete. ${editResult.appliedEdits} edits would be applied.`, structured);
|
|
108
200
|
}
|
|
109
|
-
if (appliedEdits > 0) {
|
|
110
|
-
await atomicWriteFile(validPath,
|
|
201
|
+
if (editResult.appliedEdits > 0) {
|
|
202
|
+
await atomicWriteFile(validPath, editResult.content, {
|
|
203
|
+
encoding: 'utf-8',
|
|
204
|
+
signal,
|
|
205
|
+
});
|
|
111
206
|
}
|
|
112
|
-
|
|
113
|
-
? ` — ${unmatchedEdits.length} unmatched: [${unmatchedEdits
|
|
114
|
-
.map((s) => JSON.stringify(s.length > 40 ? `${s.slice(0, 40)}\u2026` : s))
|
|
115
|
-
.join(', ')}]`
|
|
116
|
-
: '';
|
|
117
|
-
const message = appliedEdits === 0
|
|
118
|
-
? `No edits applied to ${args.path}${unmatchedNote}`
|
|
119
|
-
: `Successfully applied ${appliedEdits} edits to ${args.path}${unmatchedNote}`;
|
|
120
|
-
return buildToolResponse(message, structured);
|
|
207
|
+
return buildToolResponse(buildEditMessage(args.path, editResult), structured);
|
|
121
208
|
}
|
|
122
209
|
export function registerEditFileTool(server, options = {}) {
|
|
123
210
|
const handler = (args, extra) => executeToolWithDiagnostics({
|
|
@@ -130,27 +217,8 @@ export function registerEditFileTool(server, options = {}) {
|
|
|
130
217
|
});
|
|
131
218
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
132
219
|
guard: options.isInitialized,
|
|
133
|
-
progressMessage:
|
|
134
|
-
|
|
135
|
-
const count = args.edits.length;
|
|
136
|
-
const tag = args.dryRun ? ' [dry run]' : '';
|
|
137
|
-
return `🛠 edit: ${name} [${count} ${count === 1 ? 'edit' : 'edits'}]${tag}`;
|
|
138
|
-
},
|
|
139
|
-
completionMessage: (args, result) => {
|
|
140
|
-
const name = path.basename(args.path);
|
|
141
|
-
if (result.isError)
|
|
142
|
-
return `🛠 edit: ${name} • failed`;
|
|
143
|
-
const sc = result.structuredContent;
|
|
144
|
-
if (!sc.ok)
|
|
145
|
-
return `🛠 edit: ${name} • failed`;
|
|
146
|
-
const applied = sc.appliedEdits ?? 0;
|
|
147
|
-
const unmatched = sc.unmatchedEdits?.length ?? 0;
|
|
148
|
-
const dry = args.dryRun ? 'dry run — ' : '';
|
|
149
|
-
if (unmatched > 0) {
|
|
150
|
-
return `🛠 edit: ${name} • ${dry}${applied} applied, ${unmatched} unmatched`;
|
|
151
|
-
}
|
|
152
|
-
return `🛠 edit: ${name} • ${dry}${applied} applied`;
|
|
153
|
-
},
|
|
220
|
+
progressMessage: buildEditProgressMessage,
|
|
221
|
+
completionMessage: buildEditCompletionMessage,
|
|
154
222
|
});
|
|
155
223
|
const validatedHandler = withValidatedArgs(EditFileInputSchema, wrappedHandler);
|
|
156
224
|
if (registerToolTaskIfAvailable(server, 'edit', EDIT_FILE_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { DEFAULT_EXCLUDE_PATTERNS, MAX_LIST_ENTRIES, } from '../lib/constants.js';
|
|
4
|
+
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
4
5
|
import { listDirectory } from '../lib/file-operations/metadata.js';
|
|
5
6
|
import { formatOperationSummary, joinLines } from '../config.js';
|
|
6
7
|
import { ListDirectoryInputSchema, ListDirectoryOutputSchema, } from '../schemas.js';
|
|
7
|
-
import { buildToolErrorResponse, buildToolResponse,
|
|
8
|
+
import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
8
9
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
9
10
|
export const LIST_DIRECTORY_TOOL = {
|
|
10
11
|
name: 'ls',
|
|
@@ -18,6 +19,74 @@ export const LIST_DIRECTORY_TOOL = {
|
|
|
18
19
|
taskSupport: 'optional',
|
|
19
20
|
nuances: ['`pattern` enables filtered recursive traversal up to `maxDepth`.'],
|
|
20
21
|
};
|
|
22
|
+
const LIST_CURSOR_TTL_MS = parseInt(process.env['FS_CONTEXT_LIST_CURSOR_TTL_MS'] ?? '', 10) ||
|
|
23
|
+
5 * 60 * 1000;
|
|
24
|
+
const listSnapshots = new Map();
|
|
25
|
+
const listSnapshotTimers = new Map();
|
|
26
|
+
function buildListFingerprint(args) {
|
|
27
|
+
return JSON.stringify({
|
|
28
|
+
path: resolvePathOrRoot(args.path),
|
|
29
|
+
includeHidden: args.includeHidden,
|
|
30
|
+
includeIgnored: args.includeIgnored,
|
|
31
|
+
maxDepth: args.maxDepth,
|
|
32
|
+
sortBy: args.sortBy,
|
|
33
|
+
pattern: args.pattern,
|
|
34
|
+
includeSymlinkTargets: args.includeSymlinkTargets,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function deleteListSnapshot(snapshotId) {
|
|
38
|
+
listSnapshots.delete(snapshotId);
|
|
39
|
+
const timer = listSnapshotTimers.get(snapshotId);
|
|
40
|
+
if (timer) {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
listSnapshotTimers.delete(snapshotId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function storeListSnapshot(snapshot) {
|
|
46
|
+
const snapshotId = randomUUID();
|
|
47
|
+
listSnapshots.set(snapshotId, snapshot);
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
deleteListSnapshot(snapshotId);
|
|
50
|
+
}, LIST_CURSOR_TTL_MS);
|
|
51
|
+
if (typeof timer.unref === 'function') {
|
|
52
|
+
timer.unref();
|
|
53
|
+
}
|
|
54
|
+
listSnapshotTimers.set(snapshotId, timer);
|
|
55
|
+
return snapshotId;
|
|
56
|
+
}
|
|
57
|
+
function encodeListCursor(payload) {
|
|
58
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
59
|
+
}
|
|
60
|
+
function decodeListCursor(cursor) {
|
|
61
|
+
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
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// fall through to throw
|
|
77
|
+
}
|
|
78
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Invalid cursor: the cursor value is malformed or expired.');
|
|
79
|
+
}
|
|
80
|
+
function resolveNextListCursor(snapshotId, offset, pageSize, totalEntries) {
|
|
81
|
+
if (!snapshotId)
|
|
82
|
+
return undefined;
|
|
83
|
+
const nextOffset = offset + pageSize;
|
|
84
|
+
if (nextOffset >= totalEntries) {
|
|
85
|
+
deleteListSnapshot(snapshotId);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
return encodeListCursor({ snapshotId, offset: nextOffset });
|
|
89
|
+
}
|
|
21
90
|
function buildListTextResult(result, nextCursor) {
|
|
22
91
|
const { entries, summary, path } = result;
|
|
23
92
|
if (entries.length === 0) {
|
|
@@ -82,7 +151,6 @@ function buildStructuredListResult(result, nextCursor) {
|
|
|
82
151
|
}
|
|
83
152
|
async function handleListDirectory(args, signal) {
|
|
84
153
|
const dirPath = resolvePathOrRoot(args.path);
|
|
85
|
-
const cursorOffset = args.cursor !== undefined ? decodeOffsetCursor(args.cursor) : 0;
|
|
86
154
|
const pageSize = args.maxEntries;
|
|
87
155
|
const options = {
|
|
88
156
|
includeHidden: args.includeHidden,
|
|
@@ -90,15 +158,42 @@ async function handleListDirectory(args, signal) {
|
|
|
90
158
|
sortBy: args.sortBy,
|
|
91
159
|
includeSymlinkTargets: args.includeSymlinkTargets,
|
|
92
160
|
...(args.maxDepth !== undefined ? { maxDepth: args.maxDepth } : {}),
|
|
93
|
-
maxEntries:
|
|
161
|
+
maxEntries: MAX_LIST_ENTRIES,
|
|
94
162
|
...(args.pattern !== undefined ? { pattern: args.pattern } : {}),
|
|
95
163
|
...(signal ? { signal } : {}),
|
|
96
164
|
};
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
165
|
+
const fingerprint = buildListFingerprint(args);
|
|
166
|
+
let result;
|
|
167
|
+
let cursorOffset = 0;
|
|
168
|
+
let snapshotId;
|
|
169
|
+
if (args.cursor) {
|
|
170
|
+
const cursor = decodeListCursor(args.cursor);
|
|
171
|
+
const snapshot = listSnapshots.get(cursor.snapshotId);
|
|
172
|
+
if (snapshot?.fingerprint !== fingerprint) {
|
|
173
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Invalid cursor: the cursor value is malformed or expired.');
|
|
174
|
+
}
|
|
175
|
+
const { offset, snapshotId: storedSnapshotId } = cursor;
|
|
176
|
+
cursorOffset = offset;
|
|
177
|
+
snapshotId = storedSnapshotId;
|
|
178
|
+
result = {
|
|
179
|
+
path: snapshot.path,
|
|
180
|
+
entries: snapshot.entries,
|
|
181
|
+
summary: snapshot.summary,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
result = await listDirectory(dirPath, options);
|
|
186
|
+
}
|
|
187
|
+
const displayEntries = result.entries.slice(cursorOffset, cursorOffset + pageSize);
|
|
188
|
+
if (!args.cursor && displayEntries.length < result.entries.length) {
|
|
189
|
+
snapshotId = storeListSnapshot({
|
|
190
|
+
path: result.path,
|
|
191
|
+
entries: result.entries,
|
|
192
|
+
summary: result.summary,
|
|
193
|
+
fingerprint,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const nextCursor = resolveNextListCursor(snapshotId, cursorOffset, displayEntries.length, result.entries.length);
|
|
102
197
|
const displayResult = { ...result, entries: displayEntries };
|
|
103
198
|
return buildToolResponse(buildListTextResult(displayResult, nextCursor), buildStructuredListResult(displayResult, nextCursor));
|
|
104
199
|
}
|
package/dist/tools/move-file.js
CHANGED
|
@@ -17,7 +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: '
|
|
20
|
+
taskSupport: 'forbidden',
|
|
21
21
|
};
|
|
22
22
|
export async function handleMoveFile(args, signal) {
|
|
23
23
|
const sources = args.sources ?? (args.source ? [args.source] : []);
|
|
@@ -158,12 +158,12 @@ export function registerMoveFileTool(server, options = {}) {
|
|
|
158
158
|
const src = path.basename(args.source);
|
|
159
159
|
if (result.isError)
|
|
160
160
|
return `🛠 mv: ${src} → ${dest} • failed`;
|
|
161
|
-
return `🛠 mv: ${src} → ${dest}
|
|
161
|
+
return `🛠 mv: ${src} → ${dest}`;
|
|
162
162
|
}
|
|
163
163
|
const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
|
|
164
164
|
if (result.isError)
|
|
165
165
|
return `🛠 mv: ${count} items → ${dest} • failed`;
|
|
166
|
-
return `🛠 mv: ${count} items → ${dest}
|
|
166
|
+
return `🛠 mv: ${count} items → ${dest}`;
|
|
167
167
|
},
|
|
168
168
|
});
|
|
169
169
|
const validatedHandler = withValidatedArgs(MoveFileInputSchema, wrappedHandler);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { type ToolContract, type ToolRegistrationOptions } from './shared.js';
|
|
3
|
-
export declare const
|
|
3
|
+
export declare const READ_MANY_TOOL: ToolContract;
|
|
4
4
|
export declare function registerReadMultipleFilesTool(server: McpServer, options?: ToolRegistrationOptions): void;
|