@j0hanz/filesystem-mcp 1.13.0 → 1.13.1
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 +17 -0
- package/dist/tools/edit-file.js +1 -1
- package/dist/tools/replace-in-files.js +57 -34
- package/dist/tools/search-content.js +36 -30
- package/package.json +1 -1
package/dist/completions.js
CHANGED
|
@@ -47,6 +47,9 @@ const PATH_ARGUMENTS = new Set([
|
|
|
47
47
|
const DESTINATION_CONTEXT_KEYS = ['source', 'path', 'cwd', 'root'];
|
|
48
48
|
const PRIMARY_PATH_CONTEXT_KEYS = ['path', 'cwd', 'root'];
|
|
49
49
|
const DEFAULT_CONTEXT_KEYS = ['path', 'source', 'cwd', 'root'];
|
|
50
|
+
const ENUM_ARGUMENT_VALUES = new Map([
|
|
51
|
+
['sortby', ['modified', 'name', 'path', 'size', 'type']],
|
|
52
|
+
]);
|
|
50
53
|
function isPathLikeArgumentName(argName) {
|
|
51
54
|
return (PATH_ARGUMENTS.has(argName) ||
|
|
52
55
|
argName.endsWith('paths') ||
|
|
@@ -56,6 +59,16 @@ function isPathLikeArgumentName(argName) {
|
|
|
56
59
|
argName.endsWith('dirs') ||
|
|
57
60
|
argName.endsWith('dir'));
|
|
58
61
|
}
|
|
62
|
+
function getEnumCompletions(argName, currentValue) {
|
|
63
|
+
const values = ENUM_ARGUMENT_VALUES.get(argName);
|
|
64
|
+
if (!values)
|
|
65
|
+
return undefined;
|
|
66
|
+
const prefix = currentValue.toLowerCase();
|
|
67
|
+
const filtered = prefix
|
|
68
|
+
? values.filter((v) => v.startsWith(prefix))
|
|
69
|
+
: [...values];
|
|
70
|
+
return buildCompletionResult(filtered);
|
|
71
|
+
}
|
|
59
72
|
function isTemplateVariableChar(char) {
|
|
60
73
|
const code = char.charCodeAt(0);
|
|
61
74
|
const isDigit = code >= 48 && code <= 57;
|
|
@@ -452,6 +465,10 @@ export function registerCompletions(server, instructions = '') {
|
|
|
452
465
|
: toolNameValues;
|
|
453
466
|
return buildCompletionResponse(buildCompletionResult(filtered));
|
|
454
467
|
}
|
|
468
|
+
const enumResult = getEnumCompletions(argName, argument.value);
|
|
469
|
+
if (enumResult) {
|
|
470
|
+
return buildCompletionResponse(enumResult);
|
|
471
|
+
}
|
|
455
472
|
const isPathArg = isPathLikeArgumentName(argName) ||
|
|
456
473
|
isPathArgumentFromReference(argName, ref);
|
|
457
474
|
if (!isPathArg) {
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -185,7 +185,7 @@ function buildEditCompletionMessage(args, result) {
|
|
|
185
185
|
const added = structuredContent.linesAdded ?? 0;
|
|
186
186
|
const removed = structuredContent.linesRemoved ?? 0;
|
|
187
187
|
const dry = args.dryRun ? 'dry run ' : '';
|
|
188
|
-
return `🛠 edit: ${name} • ${dry}+${added} -${removed}`;
|
|
188
|
+
return `🛠 edit: ${name} • ${dry} +${added} -${removed}`;
|
|
189
189
|
}
|
|
190
190
|
async function applyEdits(content, edits, ignoreWhitespace) {
|
|
191
191
|
let newContent = content;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
4
|
import { Buffer } from 'node:buffer';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
4
6
|
import { createTwoFilesPatch } from 'diff';
|
|
5
7
|
import RE2 from 're2';
|
|
6
8
|
import safeRegex from 'safe-regex2';
|
|
@@ -110,6 +112,13 @@ class LiteralReplacementMatcher {
|
|
|
110
112
|
return content.replaceAll(this.searchPattern, () => replacement);
|
|
111
113
|
}
|
|
112
114
|
}
|
|
115
|
+
const replaceContextStorage = new AsyncLocalStorage();
|
|
116
|
+
function getReplaceContext() {
|
|
117
|
+
const ctx = replaceContextStorage.getStore();
|
|
118
|
+
if (!ctx)
|
|
119
|
+
throw new Error('Replace context not found in AsyncLocalStorage');
|
|
120
|
+
return ctx;
|
|
121
|
+
}
|
|
113
122
|
function buildReplacementPlan(content, replacement, matcher) {
|
|
114
123
|
const matchCount = matcher.count(content);
|
|
115
124
|
if (matchCount === 0) {
|
|
@@ -124,8 +133,8 @@ function buildReplacementPlan(content, replacement, matcher) {
|
|
|
124
133
|
function formatFileTooLargeError(filePath, size, maxFileSize) {
|
|
125
134
|
return `File too large: ${filePath} (${size} bytes > ${maxFileSize} bytes)`;
|
|
126
135
|
}
|
|
127
|
-
async function processEntry(entryPath
|
|
128
|
-
const { options,
|
|
136
|
+
async function processEntry(entryPath) {
|
|
137
|
+
const { options, signal, summary } = getReplaceContext();
|
|
129
138
|
let validPath;
|
|
130
139
|
try {
|
|
131
140
|
validPath = await validatePathForWrite(entryPath, signal);
|
|
@@ -139,12 +148,7 @@ async function processEntry(entryPath, context) {
|
|
|
139
148
|
return;
|
|
140
149
|
}
|
|
141
150
|
try {
|
|
142
|
-
const plan = await readReplacementPlan(validPath
|
|
143
|
-
matcher,
|
|
144
|
-
replacement,
|
|
145
|
-
maxFileSize,
|
|
146
|
-
signal,
|
|
147
|
-
});
|
|
151
|
+
const plan = await readReplacementPlan(validPath);
|
|
148
152
|
if (!plan) {
|
|
149
153
|
return;
|
|
150
154
|
}
|
|
@@ -172,19 +176,20 @@ async function processEntry(entryPath, context) {
|
|
|
172
176
|
});
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
|
-
async function readReplacementPlan(validPath
|
|
179
|
+
async function readReplacementPlan(validPath) {
|
|
180
|
+
const { matcher, replacement, maxFileSize, signal } = getReplaceContext();
|
|
176
181
|
let fileHandle;
|
|
177
182
|
try {
|
|
178
183
|
const fd = await fs.open(validPath, 'r');
|
|
179
184
|
fileHandle = fd;
|
|
180
185
|
const stats = await fileHandle.stat();
|
|
181
|
-
if (stats.size >
|
|
182
|
-
throw new Error(formatFileTooLargeError(validPath, stats.size,
|
|
186
|
+
if (stats.size > maxFileSize) {
|
|
187
|
+
throw new Error(formatFileTooLargeError(validPath, stats.size, maxFileSize));
|
|
183
188
|
}
|
|
184
189
|
let content;
|
|
185
|
-
if (
|
|
186
|
-
const buffer = await fileHandle.readFile({ signal
|
|
187
|
-
if (!
|
|
190
|
+
if (matcher.testBuffer) {
|
|
191
|
+
const buffer = await fileHandle.readFile({ signal });
|
|
192
|
+
if (!matcher.testBuffer(buffer)) {
|
|
188
193
|
return undefined;
|
|
189
194
|
}
|
|
190
195
|
content = buffer.toString('utf-8');
|
|
@@ -192,10 +197,10 @@ async function readReplacementPlan(validPath, context) {
|
|
|
192
197
|
else {
|
|
193
198
|
content = await fileHandle.readFile({
|
|
194
199
|
encoding: 'utf-8',
|
|
195
|
-
signal
|
|
200
|
+
signal,
|
|
196
201
|
});
|
|
197
202
|
}
|
|
198
|
-
return buildReplacementPlan(content,
|
|
203
|
+
return buildReplacementPlan(content, replacement, matcher);
|
|
199
204
|
}
|
|
200
205
|
finally {
|
|
201
206
|
if (fileHandle) {
|
|
@@ -211,10 +216,13 @@ async function maybeAppendPatchDiff(summary, params) {
|
|
|
211
216
|
return;
|
|
212
217
|
}
|
|
213
218
|
const patch = await new Promise((resolve) => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
// Defer to event loop to avoid blocking on large diffs
|
|
220
|
+
setImmediate(() => {
|
|
221
|
+
createTwoFilesPatch(path.basename(params.filePath), path.basename(params.filePath), params.originalContent, params.updatedContent, 'Original', 'Modified', {
|
|
222
|
+
callback: (res) => {
|
|
223
|
+
resolve(res ?? '');
|
|
224
|
+
},
|
|
225
|
+
});
|
|
218
226
|
});
|
|
219
227
|
});
|
|
220
228
|
if (summary.diff.length + patch.length <=
|
|
@@ -315,7 +323,22 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
|
|
|
315
323
|
suppressErrors: true,
|
|
316
324
|
});
|
|
317
325
|
const summary = createReplaceSummary(root);
|
|
318
|
-
const
|
|
326
|
+
const timerStartName = `searchAndReplaceStart_${Date.now()}`;
|
|
327
|
+
const timerEndName = `searchAndReplaceEnd_${Date.now()}`;
|
|
328
|
+
const metricName = `searchAndReplace_${Date.now()}`;
|
|
329
|
+
performance.mark(timerStartName);
|
|
330
|
+
const context = {
|
|
331
|
+
options: {
|
|
332
|
+
dryRun: args.dryRun,
|
|
333
|
+
returnDiff: args.returnDiff ?? false,
|
|
334
|
+
},
|
|
335
|
+
replacement: args.replacement,
|
|
336
|
+
matcher,
|
|
337
|
+
maxFileSize,
|
|
338
|
+
signal,
|
|
339
|
+
summary,
|
|
340
|
+
};
|
|
341
|
+
const { stoppedByLimit } = await replaceContextStorage.run(context, () => processEntriesConcurrently(entries, {
|
|
319
342
|
signal,
|
|
320
343
|
concurrency: REPLACE_CONCURRENCY,
|
|
321
344
|
...(args.maxFiles !== undefined ? { maxEntries: args.maxFiles } : {}),
|
|
@@ -325,18 +348,15 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
|
|
|
325
348
|
throttleModulo: 25,
|
|
326
349
|
});
|
|
327
350
|
},
|
|
328
|
-
runEntry:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
summary,
|
|
338
|
-
}),
|
|
339
|
-
});
|
|
351
|
+
runEntry: processEntry,
|
|
352
|
+
}));
|
|
353
|
+
performance.mark(timerEndName);
|
|
354
|
+
performance.measure(metricName, timerStartName, timerEndName);
|
|
355
|
+
const durationMs = performance.getEntriesByName(metricName)[0]?.duration ?? 0;
|
|
356
|
+
performance.clearMarks(timerStartName);
|
|
357
|
+
performance.clearMarks(timerEndName);
|
|
358
|
+
performance.clearMeasures(metricName);
|
|
359
|
+
summary.perfTimeMs = durationMs;
|
|
340
360
|
if (stoppedByLimit) {
|
|
341
361
|
summary.stoppedReason = 'maxFiles';
|
|
342
362
|
}
|
|
@@ -394,7 +414,10 @@ export function registerSearchAndReplaceTool(server, options = {}) {
|
|
|
394
414
|
function buildSearchAndReplaceText(summary, dryRun) {
|
|
395
415
|
const failureSuffix = summary.failedFiles > 0 ? ` (${summary.failedFiles} failed)` : '';
|
|
396
416
|
const dryRunSuffix = dryRun ? ' (Dry run)' : '';
|
|
397
|
-
|
|
417
|
+
const timing = summary.perfTimeMs
|
|
418
|
+
? ` [\u23F1\uFE0F ${summary.perfTimeMs.toFixed(0)}ms]`
|
|
419
|
+
: '';
|
|
420
|
+
return `Found ${summary.totalMatches} matches in ${summary.filesChanged} files${failureSuffix}.${dryRunSuffix}${timing}`;
|
|
398
421
|
}
|
|
399
422
|
function buildSearchAndReplaceStructuredResult(summary, args) {
|
|
400
423
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
2
3
|
import RE2 from 're2';
|
|
3
4
|
import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
|
|
4
5
|
import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
|
|
@@ -18,6 +19,7 @@ const CONFIG = {
|
|
|
18
19
|
maxFiles: 'max files',
|
|
19
20
|
},
|
|
20
21
|
};
|
|
22
|
+
let searchMetricSequence = 0;
|
|
21
23
|
const TRUTHY_SUMMARY_FIELDS = [
|
|
22
24
|
'filesMatched',
|
|
23
25
|
'skippedTooLarge',
|
|
@@ -26,15 +28,20 @@ const TRUTHY_SUMMARY_FIELDS = [
|
|
|
26
28
|
'linesSkippedDueToRegexTimeout',
|
|
27
29
|
];
|
|
28
30
|
function buildStructuredSummaryFields(summary) {
|
|
29
|
-
const
|
|
31
|
+
const result = {};
|
|
32
|
+
for (const key of TRUTHY_SUMMARY_FIELDS) {
|
|
30
33
|
const value = summary[key];
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
34
|
+
if (value) {
|
|
35
|
+
result[key] = value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (summary.truncated) {
|
|
39
|
+
result.truncated = true;
|
|
40
|
+
}
|
|
41
|
+
if (summary.stoppedReason) {
|
|
42
|
+
result.stoppedReason = summary.stoppedReason;
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
38
45
|
}
|
|
39
46
|
function buildCompletionSuffix(count, filesMatched, scope, stoppedReason) {
|
|
40
47
|
if (count === 0)
|
|
@@ -46,6 +53,15 @@ function buildCompletionSuffix(count, filesMatched, scope, stoppedReason) {
|
|
|
46
53
|
: '';
|
|
47
54
|
return `${count} ${matchWord} in ${filesMatched} ${fileWord}${reasonSuffix}`;
|
|
48
55
|
}
|
|
56
|
+
function createSearchMetricNames() {
|
|
57
|
+
searchMetricSequence += 1;
|
|
58
|
+
const metricSuffix = `${Date.now()}_${searchMetricSequence}`;
|
|
59
|
+
return {
|
|
60
|
+
timerStartName: `searchContentStart_${metricSuffix}`,
|
|
61
|
+
timerEndName: `searchContentEnd_${metricSuffix}`,
|
|
62
|
+
metricName: `searchContent_${metricSuffix}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
49
65
|
function compareNormalizedMatches(left, right) {
|
|
50
66
|
const fileCompare = left.relativeFile.localeCompare(right.relativeFile);
|
|
51
67
|
if (fileCompare !== 0)
|
|
@@ -159,30 +175,11 @@ const SearchResponseBuilder = {
|
|
|
159
175
|
buildMatchList(heading, matches) {
|
|
160
176
|
if (matches.length === 0)
|
|
161
177
|
return heading;
|
|
162
|
-
|
|
163
|
-
// +1 for newline after heading. Each match gets: " " (2) + relativeFile + ":" + line + ": " (2) + content + "\n"
|
|
164
|
-
let totalBytes = Buffer.byteLength(heading, 'utf8');
|
|
178
|
+
const parts = [heading];
|
|
165
179
|
for (const match of matches) {
|
|
166
|
-
|
|
167
|
-
1 + // \n
|
|
168
|
-
2 + // " "
|
|
169
|
-
Buffer.byteLength(match.relativeFile, 'utf8') +
|
|
170
|
-
1 + // ":"
|
|
171
|
-
Math.max(4, String(match.line).length) +
|
|
172
|
-
2 + // ": "
|
|
173
|
-
Buffer.byteLength(match.content, 'utf8');
|
|
180
|
+
parts.push(`\n ${match.relativeFile}:${String(match.line).padStart(4)}: ${match.content}`);
|
|
174
181
|
}
|
|
175
|
-
|
|
176
|
-
let offset = buf.write(heading, 0, 'utf8');
|
|
177
|
-
for (const match of matches) {
|
|
178
|
-
offset += buf.write('\n ', offset, 'utf8');
|
|
179
|
-
offset += buf.write(match.relativeFile, offset, 'utf8');
|
|
180
|
-
offset += buf.write(':', offset, 'utf8');
|
|
181
|
-
offset += buf.write(String(match.line).padStart(4), offset, 'utf8');
|
|
182
|
-
offset += buf.write(': ', offset, 'utf8');
|
|
183
|
-
offset += buf.write(match.content, offset, 'utf8');
|
|
184
|
-
}
|
|
185
|
-
return buf.toString('utf8', 0, offset);
|
|
182
|
+
return parts.join('');
|
|
186
183
|
},
|
|
187
184
|
resolveTruncatedReason(summary) {
|
|
188
185
|
if (summary.stoppedReason === 'timeout')
|
|
@@ -215,6 +212,7 @@ const SearchResponseBuilder = {
|
|
|
215
212
|
const SearchExecutor = {
|
|
216
213
|
async run(args, basePath, signal, onProgress) {
|
|
217
214
|
const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
|
|
215
|
+
const { timerStartName, timerEndName, metricName } = createSearchMetricNames();
|
|
218
216
|
const options = {
|
|
219
217
|
includeHidden: args.includeHidden,
|
|
220
218
|
excludePatterns,
|
|
@@ -228,6 +226,7 @@ const SearchExecutor = {
|
|
|
228
226
|
...(signal ? { signal } : {}),
|
|
229
227
|
...(onProgress ? { onProgress } : {}),
|
|
230
228
|
};
|
|
229
|
+
performance.mark(timerStartName);
|
|
231
230
|
try {
|
|
232
231
|
return await searchContent(basePath, args.pattern, options);
|
|
233
232
|
}
|
|
@@ -237,6 +236,13 @@ const SearchExecutor = {
|
|
|
237
236
|
}
|
|
238
237
|
throw error;
|
|
239
238
|
}
|
|
239
|
+
finally {
|
|
240
|
+
performance.mark(timerEndName);
|
|
241
|
+
performance.measure(metricName, timerStartName, timerEndName);
|
|
242
|
+
performance.clearMarks(timerStartName);
|
|
243
|
+
performance.clearMarks(timerEndName);
|
|
244
|
+
performance.clearMeasures(metricName);
|
|
245
|
+
}
|
|
240
246
|
},
|
|
241
247
|
createMatcher(args) {
|
|
242
248
|
if (!args.isRegex)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@j0hanz/filesystem-mcp",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.1",
|
|
4
4
|
"mcpName": "io.github.j0hanz/filesystem-mcp",
|
|
5
5
|
"description": "A local filesystem MCP server that lets LLMs and AI agents read, write, search, diff, patch, and manage files safely and efficiently. Built for reliable, structured, and controlled filesystem interaction.",
|
|
6
6
|
"type": "module",
|