@j0hanz/filesystem-mcp 1.11.1 → 1.13.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/lib/utils.d.ts +4 -0
- package/dist/lib/utils.js +26 -0
- 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/server/roots-manager.d.ts +1 -1
- package/dist/server/roots-manager.js +8 -13
- package/dist/tools/apply-patch.js +34 -38
- 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 +183 -115
- 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 +266 -217
- 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
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,203 @@ export const EDIT_FILE_TOOL = {
|
|
|
25
25
|
function escapeRegExp(string) {
|
|
26
26
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
27
27
|
}
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
let
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
37
|
}
|
|
38
|
-
return
|
|
38
|
+
return count;
|
|
39
39
|
}
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
40
|
+
function countLines(str) {
|
|
41
|
+
return getLineNumberAtIndex(str);
|
|
42
|
+
}
|
|
43
|
+
async function computeDiffStats(original, modified) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
diffLines(original, modified, {
|
|
46
|
+
callback: (changes) => {
|
|
47
|
+
let linesAdded = 0;
|
|
48
|
+
let linesRemoved = 0;
|
|
49
|
+
for (const part of changes) {
|
|
50
|
+
if (part.added) {
|
|
51
|
+
linesAdded += part.count;
|
|
52
|
+
}
|
|
53
|
+
else if (part.removed) {
|
|
54
|
+
linesRemoved += part.count;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
resolve({ linesAdded, linesRemoved });
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function findEditMatch(content, oldText, ignoreWhitespace, regexCache) {
|
|
63
|
+
if (ignoreWhitespace) {
|
|
64
|
+
const pattern = escapeRegExp(oldText).replace(/\s+/g, '\\s+');
|
|
65
|
+
let regex = regexCache?.get(pattern);
|
|
66
|
+
if (!regex) {
|
|
67
|
+
regex = new RE2(pattern);
|
|
68
|
+
if (regexCache)
|
|
69
|
+
regexCache.set(pattern, regex);
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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;
|
|
71
|
+
const match = regex.exec(content);
|
|
72
|
+
if (!match) {
|
|
73
|
+
return undefined;
|
|
87
74
|
}
|
|
75
|
+
return {
|
|
76
|
+
startIndex: match.index,
|
|
77
|
+
length: match[0].length,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const index = content.indexOf(oldText);
|
|
81
|
+
if (index === -1) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
startIndex: index,
|
|
86
|
+
length: oldText.length,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function replaceEditMatch(content, match, newText) {
|
|
90
|
+
return (content.slice(0, match.startIndex) +
|
|
91
|
+
newText +
|
|
92
|
+
content.slice(match.startIndex + match.length));
|
|
93
|
+
}
|
|
94
|
+
function mergeLineRange(currentRange, content, matchStartIndex, newText) {
|
|
95
|
+
const startLine = getLineNumberAtIndex(content, matchStartIndex);
|
|
96
|
+
const endLine = startLine + countLines(newText) - 1;
|
|
97
|
+
if (!currentRange) {
|
|
98
|
+
return [startLine, endLine];
|
|
88
99
|
}
|
|
100
|
+
return [
|
|
101
|
+
Math.min(currentRange[0], startLine),
|
|
102
|
+
Math.max(currentRange[1], endLine),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
function buildStructuredEditOutput(validPath, result) {
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
path: validPath,
|
|
109
|
+
appliedEdits: result.appliedEdits,
|
|
110
|
+
...(result.appliedEdits > 0
|
|
111
|
+
? {
|
|
112
|
+
linesAdded: result.linesAdded,
|
|
113
|
+
linesRemoved: result.linesRemoved,
|
|
114
|
+
}
|
|
115
|
+
: {}),
|
|
116
|
+
...(result.unmatchedEdits.length > 0
|
|
117
|
+
? { unmatchedEdits: result.unmatchedEdits }
|
|
118
|
+
: {}),
|
|
119
|
+
...(result.lineRange ? { lineRange: result.lineRange } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async function finalizeEditResult(originalContent, updatedContent, appliedEdits, unmatchedEdits, lineRange) {
|
|
89
123
|
const { linesAdded, linesRemoved } = appliedEdits > 0
|
|
90
|
-
? computeDiffStats(
|
|
124
|
+
? await computeDiffStats(originalContent, updatedContent)
|
|
91
125
|
: { linesAdded: 0, linesRemoved: 0 };
|
|
92
|
-
|
|
93
|
-
content:
|
|
126
|
+
return {
|
|
127
|
+
content: updatedContent,
|
|
94
128
|
appliedEdits,
|
|
95
129
|
unmatchedEdits,
|
|
96
130
|
linesAdded,
|
|
97
131
|
linesRemoved,
|
|
132
|
+
...(lineRange ? { lineRange } : {}),
|
|
98
133
|
};
|
|
99
|
-
|
|
100
|
-
|
|
134
|
+
}
|
|
135
|
+
async function buildDiff(validPath, original, modified) {
|
|
136
|
+
const fileName = basename(validPath);
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
createTwoFilesPatch(fileName, fileName, original, modified, 'Original', 'Modified', {
|
|
139
|
+
callback: (res) => {
|
|
140
|
+
resolve(res ?? '');
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function formatUnmatchedEditsNote(unmatchedEdits) {
|
|
146
|
+
if (unmatchedEdits.length === 0) {
|
|
147
|
+
return '';
|
|
101
148
|
}
|
|
102
|
-
return
|
|
149
|
+
return ` — ${unmatchedEdits.length} unmatched: [${unmatchedEdits
|
|
150
|
+
.map((text) => JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}…` : text))
|
|
151
|
+
.join(', ')}]`;
|
|
103
152
|
}
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
153
|
+
function buildEditMessage(requestedPath, result) {
|
|
154
|
+
const unmatchedNote = formatUnmatchedEditsNote(result.unmatchedEdits);
|
|
155
|
+
if (result.appliedEdits === 0) {
|
|
156
|
+
return `No edits applied to ${requestedPath}${unmatchedNote}`;
|
|
157
|
+
}
|
|
158
|
+
return `Successfully applied ${result.appliedEdits} edits to ${requestedPath}${unmatchedNote}`;
|
|
159
|
+
}
|
|
160
|
+
async function loadEditableFile(requestedPath, signal) {
|
|
161
|
+
const validPath = await validateExistingPath(requestedPath, signal);
|
|
162
|
+
assertAllowedFileAccess(requestedPath, validPath);
|
|
163
|
+
const stats = await withAbort(stat(validPath), signal);
|
|
108
164
|
if (stats.size > MAX_TEXT_FILE_SIZE) {
|
|
109
|
-
throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for edit: ${
|
|
165
|
+
throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for edit: ${requestedPath} (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, requestedPath, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
|
|
110
166
|
}
|
|
111
|
-
const content = await
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
167
|
+
const content = await readFile(validPath, { encoding: 'utf-8', signal });
|
|
168
|
+
return { validPath, content };
|
|
169
|
+
}
|
|
170
|
+
function buildEditProgressMessage(args) {
|
|
171
|
+
const name = basename(args.path);
|
|
172
|
+
const tag = args.dryRun ? ' [dry run]' : '';
|
|
173
|
+
return `🛠 edit: ${name}${tag}`;
|
|
174
|
+
}
|
|
175
|
+
function buildEditCompletionMessage(args, result) {
|
|
176
|
+
const name = basename(args.path);
|
|
177
|
+
if (result.isError)
|
|
178
|
+
return `🛠 edit: ${name} • failed`;
|
|
179
|
+
const { structuredContent } = result;
|
|
180
|
+
if (!structuredContent.ok)
|
|
181
|
+
return `🛠 edit: ${name} • failed`;
|
|
182
|
+
const applied = structuredContent.appliedEdits ?? 0;
|
|
183
|
+
if (applied === 0)
|
|
184
|
+
return `🛠 edit: ${name} • no changes`;
|
|
185
|
+
const added = structuredContent.linesAdded ?? 0;
|
|
186
|
+
const removed = structuredContent.linesRemoved ?? 0;
|
|
187
|
+
const dry = args.dryRun ? 'dry run ' : '';
|
|
188
|
+
return `🛠 edit: ${name} • ${dry}+${added} -${removed}`;
|
|
189
|
+
}
|
|
190
|
+
async function applyEdits(content, edits, ignoreWhitespace) {
|
|
191
|
+
let newContent = content;
|
|
192
|
+
let appliedEdits = 0;
|
|
193
|
+
const unmatchedEdits = [];
|
|
194
|
+
let lineRange;
|
|
195
|
+
const regexCache = ignoreWhitespace ? new Map() : undefined;
|
|
196
|
+
for (const edit of edits) {
|
|
197
|
+
const match = findEditMatch(newContent, edit.oldText, ignoreWhitespace, regexCache);
|
|
198
|
+
if (!match) {
|
|
199
|
+
unmatchedEdits.push(edit.oldText);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
lineRange = mergeLineRange(lineRange, newContent, match.startIndex, edit.newText);
|
|
203
|
+
newContent = replaceEditMatch(newContent, match, edit.newText);
|
|
204
|
+
appliedEdits += 1;
|
|
205
|
+
}
|
|
206
|
+
return finalizeEditResult(content, newContent, appliedEdits, unmatchedEdits, lineRange);
|
|
207
|
+
}
|
|
208
|
+
export async function handleEditFile(args, signal) {
|
|
209
|
+
const { validPath, content } = await loadEditableFile(args.path, signal);
|
|
210
|
+
const editResult = await applyEdits(content, args.edits, args.ignoreWhitespace);
|
|
211
|
+
const structured = buildStructuredEditOutput(validPath, editResult);
|
|
121
212
|
if (args.dryRun) {
|
|
122
|
-
if (appliedEdits > 0) {
|
|
123
|
-
structured.diff =
|
|
213
|
+
if (editResult.appliedEdits > 0) {
|
|
214
|
+
structured.diff = await buildDiff(validPath, content, editResult.content);
|
|
124
215
|
}
|
|
125
|
-
return buildToolResponse(`Dry run complete. ${appliedEdits} edits would be applied.`, structured);
|
|
216
|
+
return buildToolResponse(`Dry run complete. ${editResult.appliedEdits} edits would be applied.`, structured);
|
|
126
217
|
}
|
|
127
|
-
if (appliedEdits > 0) {
|
|
128
|
-
await atomicWriteFile(validPath,
|
|
218
|
+
if (editResult.appliedEdits > 0) {
|
|
219
|
+
await atomicWriteFile(validPath, editResult.content, {
|
|
220
|
+
encoding: 'utf-8',
|
|
221
|
+
signal,
|
|
222
|
+
});
|
|
129
223
|
}
|
|
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);
|
|
224
|
+
return buildToolResponse(buildEditMessage(args.path, editResult), structured);
|
|
139
225
|
}
|
|
140
226
|
export function registerEditFileTool(server, options = {}) {
|
|
141
227
|
const handler = (args, extra) => executeToolWithDiagnostics({
|
|
@@ -148,26 +234,8 @@ export function registerEditFileTool(server, options = {}) {
|
|
|
148
234
|
});
|
|
149
235
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
150
236
|
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
|
-
},
|
|
237
|
+
progressMessage: buildEditProgressMessage,
|
|
238
|
+
completionMessage: buildEditCompletionMessage,
|
|
171
239
|
});
|
|
172
240
|
const validatedHandler = withValidatedArgs(EditFileInputSchema, wrappedHandler);
|
|
173
241
|
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
|
}
|
|
@@ -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;
|