@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.
@@ -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) {
@@ -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, context) {
128
- const { options, replacement, matcher, maxFileSize, signal, summary } = context;
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, context) {
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 > context.maxFileSize) {
182
- throw new Error(formatFileTooLargeError(validPath, stats.size, context.maxFileSize));
186
+ if (stats.size > maxFileSize) {
187
+ throw new Error(formatFileTooLargeError(validPath, stats.size, maxFileSize));
183
188
  }
184
189
  let content;
185
- if (context.matcher.testBuffer) {
186
- const buffer = await fileHandle.readFile({ signal: context.signal });
187
- if (!context.matcher.testBuffer(buffer)) {
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: context.signal,
200
+ signal,
196
201
  });
197
202
  }
198
- return buildReplacementPlan(content, context.replacement, context.matcher);
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
- createTwoFilesPatch(path.basename(params.filePath), path.basename(params.filePath), params.originalContent, params.updatedContent, 'Original', 'Modified', {
215
- callback: (res) => {
216
- resolve(res ?? '');
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 { stoppedByLimit } = await processEntriesConcurrently(entries, {
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: async (entryPath) => processEntry(entryPath, {
329
- options: {
330
- dryRun: args.dryRun,
331
- returnDiff: args.returnDiff ?? false,
332
- },
333
- replacement: args.replacement,
334
- matcher,
335
- maxFileSize,
336
- signal,
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
- return `Found ${summary.totalMatches} matches in ${summary.filesChanged} files${failureSuffix}.${dryRunSuffix}`;
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 truthyFields = Object.fromEntries(TRUTHY_SUMMARY_FIELDS.flatMap((key) => {
31
+ const result = {};
32
+ for (const key of TRUTHY_SUMMARY_FIELDS) {
30
33
  const value = summary[key];
31
- return value ? [[key, value]] : [];
32
- }));
33
- return {
34
- ...truthyFields,
35
- ...(summary.truncated ? { truncated: true } : {}),
36
- ...(summary.stoppedReason ? { stoppedReason: summary.stoppedReason } : {}),
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
- // Fast path: calculate exact byte length to avoid arrays and string concatenation in V8
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
- totalBytes +=
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
- const buf = Buffer.allocUnsafe(totalBytes);
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.0",
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",