@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.
Files changed (42) hide show
  1. package/dist/completions.js +25 -0
  2. package/dist/lib/constants.d.ts +2 -0
  3. package/dist/lib/constants.js +2 -0
  4. package/dist/lib/file-operations/metadata.js +71 -142
  5. package/dist/lib/file-operations/search.js +119 -19
  6. package/dist/lib/file-operations/traversal.js +45 -102
  7. package/dist/lib/fs-helpers.js +175 -158
  8. package/dist/prompts.d.ts +1 -0
  9. package/dist/prompts.js +58 -0
  10. package/dist/resources/generated-instructions.js +11 -3
  11. package/dist/resources/tool-catalog.js +49 -3
  12. package/dist/resources/tool-info.d.ts +1 -0
  13. package/dist/resources/tool-info.js +139 -8
  14. package/dist/resources.js +2 -0
  15. package/dist/schemas.d.ts +2 -0
  16. package/dist/schemas.js +10 -4
  17. package/dist/server/bootstrap.js +41 -5
  18. package/dist/tools/apply-patch.js +30 -33
  19. package/dist/tools/calculate-hash.js +21 -24
  20. package/dist/tools/contract.d.ts +1 -1
  21. package/dist/tools/create-directory.js +3 -3
  22. package/dist/tools/delete-file.js +2 -2
  23. package/dist/tools/diff-files.js +32 -20
  24. package/dist/tools/edit-file.d.ts +4 -1
  25. package/dist/tools/edit-file.js +172 -104
  26. package/dist/tools/list-directory.js +105 -10
  27. package/dist/tools/move-file.js +3 -3
  28. package/dist/tools/read-multiple.d.ts +1 -1
  29. package/dist/tools/read-multiple.js +123 -99
  30. package/dist/tools/read.js +104 -76
  31. package/dist/tools/replace-in-files.d.ts +5 -2
  32. package/dist/tools/replace-in-files.js +142 -76
  33. package/dist/tools/roots.js +1 -1
  34. package/dist/tools/search-content.js +249 -218
  35. package/dist/tools/shared.js +39 -7
  36. package/dist/tools/stat-many.js +12 -19
  37. package/dist/tools/stat.js +1 -1
  38. package/dist/tools/task-support.d.ts +7 -0
  39. package/dist/tools/task-support.js +140 -101
  40. package/dist/tools/write-file.js +4 -4
  41. package/dist/tools.js +2 -2
  42. package/package.json +1 -1
@@ -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';
@@ -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: 'optional',
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',
@@ -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
- return `🕮 diff: ${n1} ⟷ ${n2} • changed`;
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
- 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,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 \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,
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: 'optional',
23
+ taskSupport: 'forbidden',
24
24
  };
25
25
  function escapeRegExp(string) {
26
26
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
27
  }
28
- function applyEdits(content, edits, ignoreWhitespace) {
29
- let newContent = content;
30
- let appliedEdits = 0;
31
- const unmatchedEdits = [];
32
- let minLine;
33
- let maxLine;
34
- for (const edit of edits) {
35
- if (ignoreWhitespace) {
36
- const pattern = escapeRegExp(edit.oldText).replace(/\s+/g, '\\s+');
37
- const regex = new RE2(pattern);
38
- const match = regex.exec(newContent);
39
- if (!match) {
40
- unmatchedEdits.push(edit.oldText);
41
- continue;
42
- }
43
- const { index } = match;
44
- const matchLength = match[0].length;
45
- const linesBefore = newContent.slice(0, index).split('\n').length;
46
- const newTextLines = edit.newText.split('\n').length;
47
- const startLine = linesBefore;
48
- const endLine = linesBefore + newTextLines - 1;
49
- if (minLine === undefined || startLine < minLine)
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
- if (!newContent.includes(edit.oldText)) {
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
- const result = {
78
- content: newContent,
79
- appliedEdits,
80
- unmatchedEdits,
81
- };
82
- if (minLine !== undefined && maxLine !== undefined) {
83
- result.lineRange = [minLine, maxLine];
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
- return result;
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
- export async function handleEditFile(args, signal) {
88
- const validPath = await validateExistingPath(args.path, signal);
89
- assertAllowedFileAccess(args.path, validPath);
90
- const stats = await withAbort(fs.stat(validPath), signal);
91
- if (stats.size > MAX_TEXT_FILE_SIZE) {
92
- 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 });
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
- const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
95
- const { content: newContent, appliedEdits, unmatchedEdits, lineRange, } = applyEdits(content, args.edits, args.ignoreWhitespace);
96
- const structured = {
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
- ...(unmatchedEdits.length > 0 ? { unmatchedEdits } : {}),
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 = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
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, newContent, { encoding: 'utf-8', signal });
201
+ if (editResult.appliedEdits > 0) {
202
+ await atomicWriteFile(validPath, editResult.content, {
203
+ encoding: 'utf-8',
204
+ signal,
205
+ });
111
206
  }
112
- const unmatchedNote = unmatchedEdits.length > 0
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: (args) => {
134
- const name = path.basename(args.path);
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 { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
3
- import { ErrorCode } from '../lib/errors.js';
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, decodeOffsetCursor, encodeOffsetCursor, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
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: cursorOffset + pageSize,
161
+ maxEntries: MAX_LIST_ENTRIES,
94
162
  ...(args.pattern !== undefined ? { pattern: args.pattern } : {}),
95
163
  ...(signal ? { signal } : {}),
96
164
  };
97
- const result = await listDirectory(dirPath, options);
98
- const displayEntries = cursorOffset > 0 ? result.entries.slice(cursorOffset) : result.entries;
99
- const nextCursor = result.summary.truncated && displayEntries.length > 0
100
- ? encodeOffsetCursor(cursorOffset + displayEntries.length)
101
- : undefined;
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
  }
@@ -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: 'optional',
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} • moved`;
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} • moved`;
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 READ_MULTIPLE_FILES_TOOL: ToolContract;
3
+ export declare const READ_MANY_TOOL: ToolContract;
4
4
  export declare function registerReadMultipleFilesTool(server: McpServer, options?: ToolRegistrationOptions): void;