@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.
@@ -1,6 +1,6 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
3
- import { createTwoFilesPatch } from 'diff';
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { createTwoFilesPatch, diffLines } from 'diff';
4
4
  import RE2 from 're2';
5
5
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
6
6
  import { ErrorCode, McpError } from '../lib/errors.js';
@@ -13,7 +13,7 @@ export const EDIT_FILE_TOOL = {
13
13
  name: 'edit',
14
14
  title: 'Edit File',
15
15
  description: 'Apply sequential literal string replacements to a file (first occurrence per edit). ' +
16
- '`oldText` must match exactly \u2014 include 3\u20135 lines of context for unique targeting. ' +
16
+ '`oldText` must match exactly — include 3–5 lines of context for unique targeting. ' +
17
17
  'Use `dryRun:true` to preview.',
18
18
  inputSchema: EditFileInputSchema,
19
19
  outputSchema: EditFileOutputSchema,
@@ -25,117 +25,203 @@ export const EDIT_FILE_TOOL = {
25
25
  function escapeRegExp(string) {
26
26
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
27
  }
28
- function computeDiffStats(original, modified) {
29
- const patch = createTwoFilesPatch('a', 'b', original, modified);
30
- let linesAdded = 0;
31
- let linesRemoved = 0;
32
- for (const line of patch.split('\n')) {
33
- if (line.startsWith('+') && !line.startsWith('+++'))
34
- linesAdded++;
35
- else if (line.startsWith('-') && !line.startsWith('---'))
36
- linesRemoved++;
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 { linesAdded, linesRemoved };
38
+ return count;
39
39
  }
40
- function applyEdits(content, edits, ignoreWhitespace) {
41
- let newContent = content;
42
- let appliedEdits = 0;
43
- const unmatchedEdits = [];
44
- let minLine;
45
- let maxLine;
46
- for (const edit of edits) {
47
- if (ignoreWhitespace) {
48
- const pattern = escapeRegExp(edit.oldText).replace(/\s+/g, '\\s+');
49
- const regex = new RE2(pattern);
50
- const match = regex.exec(newContent);
51
- if (!match) {
52
- unmatchedEdits.push(edit.oldText);
53
- continue;
54
- }
55
- const { index } = match;
56
- const matchLength = match[0].length;
57
- const linesBefore = newContent.slice(0, index).split('\n').length;
58
- const newTextLines = edit.newText.split('\n').length;
59
- const startLine = linesBefore;
60
- const endLine = linesBefore + newTextLines - 1;
61
- if (minLine === undefined || startLine < minLine)
62
- minLine = startLine;
63
- if (maxLine === undefined || endLine > maxLine)
64
- maxLine = endLine;
65
- newContent =
66
- newContent.slice(0, index) +
67
- edit.newText +
68
- newContent.slice(index + matchLength);
69
- appliedEdits += 1;
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
- 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;
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(content, newContent)
124
+ ? await computeDiffStats(originalContent, updatedContent)
91
125
  : { linesAdded: 0, linesRemoved: 0 };
92
- const result = {
93
- content: newContent,
126
+ return {
127
+ content: updatedContent,
94
128
  appliedEdits,
95
129
  unmatchedEdits,
96
130
  linesAdded,
97
131
  linesRemoved,
132
+ ...(lineRange ? { lineRange } : {}),
98
133
  };
99
- if (minLine !== undefined && maxLine !== undefined) {
100
- result.lineRange = [minLine, maxLine];
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 result;
149
+ return ` — ${unmatchedEdits.length} unmatched: [${unmatchedEdits
150
+ .map((text) => JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}…` : text))
151
+ .join(', ')}]`;
103
152
  }
104
- export async function handleEditFile(args, signal) {
105
- const validPath = await validateExistingPath(args.path, signal);
106
- assertAllowedFileAccess(args.path, validPath);
107
- const stats = await withAbort(fs.stat(validPath), signal);
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: ${args.path} (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, args.path, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
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 fs.readFile(validPath, { encoding: 'utf-8', signal });
112
- const { content: newContent, appliedEdits, unmatchedEdits, linesAdded, linesRemoved, lineRange, } = applyEdits(content, args.edits, args.ignoreWhitespace);
113
- const structured = {
114
- ok: true,
115
- path: validPath,
116
- appliedEdits,
117
- ...(appliedEdits > 0 ? { linesAdded, linesRemoved } : {}),
118
- ...(unmatchedEdits.length > 0 ? { unmatchedEdits } : {}),
119
- ...(lineRange ? { lineRange } : {}),
120
- };
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 = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
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, newContent, { encoding: 'utf-8', signal });
218
+ if (editResult.appliedEdits > 0) {
219
+ await atomicWriteFile(validPath, editResult.content, {
220
+ encoding: 'utf-8',
221
+ signal,
222
+ });
129
223
  }
130
- const unmatchedNote = unmatchedEdits.length > 0
131
- ? ` — ${unmatchedEdits.length} unmatched: [${unmatchedEdits
132
- .map((s) => JSON.stringify(s.length > 40 ? `${s.slice(0, 40)}\u2026` : s))
133
- .join(', ')}]`
134
- : '';
135
- const message = appliedEdits === 0
136
- ? `No edits applied to ${args.path}${unmatchedNote}`
137
- : `Successfully applied ${appliedEdits} edits to ${args.path}${unmatchedNote}`;
138
- return buildToolResponse(message, structured);
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: (args) => {
152
- const name = path.basename(args.path);
153
- const tag = args.dryRun ? ' [dry run]' : '';
154
- return `🛠 edit: ${name}${tag}`;
155
- },
156
- completionMessage: (args, result) => {
157
- const name = path.basename(args.path);
158
- if (result.isError)
159
- return `🛠 edit: ${name} • failed`;
160
- const sc = result.structuredContent;
161
- if (!sc.ok)
162
- return `🛠 edit: ${name} • failed`;
163
- const applied = sc.appliedEdits ?? 0;
164
- if (applied === 0)
165
- return `🛠 edit: ${name} • no changes`;
166
- const added = sc.linesAdded ?? 0;
167
- const removed = sc.linesRemoved ?? 0;
168
- const dry = args.dryRun ? 'dry run — ' : '';
169
- return `🛠 edit: ${name} • ${dry} +${added} -${removed}`;
170
- },
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 { 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
  }
@@ -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;