@j0hanz/filesystem-mcp 1.12.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) {
@@ -1,4 +1,8 @@
1
1
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
2
+ export declare function debounce<Args extends unknown[]>(func: (...args: Args) => void, waitMs: number): {
3
+ (...args: Args): void;
4
+ cancel: () => void;
5
+ };
2
6
  export declare function mergeOptions<T extends object>(defaults: T, overrides: Partial<T>): T;
3
7
  export declare function omitOptionKeys<T extends object, K extends keyof T>(input: T, keys: readonly K[]): Omit<T, K>;
4
8
  export declare function setIfDefined<T extends object, K extends keyof T>(target: T, key: K, value: T[K] | undefined): void;
package/dist/lib/utils.js CHANGED
@@ -2,6 +2,32 @@
2
2
  export function isRecord(value) {
3
3
  return value !== null && typeof value === 'object';
4
4
  }
5
+ // debounce
6
+ export function debounce(func, waitMs) {
7
+ let timeoutId;
8
+ const debounced = (...args) => {
9
+ if (timeoutId !== undefined) {
10
+ clearTimeout(timeoutId);
11
+ }
12
+ timeoutId = setTimeout(() => {
13
+ timeoutId = undefined;
14
+ func(...args);
15
+ }, waitMs);
16
+ // Unref if in Node environment to not block process exit
17
+ const nodeTimeout = timeoutId;
18
+ if (typeof nodeTimeout === 'object' &&
19
+ typeof nodeTimeout.unref === 'function') {
20
+ nodeTimeout.unref();
21
+ }
22
+ };
23
+ debounced.cancel = () => {
24
+ if (timeoutId !== undefined) {
25
+ clearTimeout(timeoutId);
26
+ timeoutId = undefined;
27
+ }
28
+ };
29
+ return debounced;
30
+ }
5
31
  // option-utils.ts
6
32
  export function mergeOptions(defaults, overrides) {
7
33
  return { ...defaults, ...overrides };
@@ -3,7 +3,7 @@ import { type AllowedDirectoriesState } from '../lib/paths.js';
3
3
  import { type LoggingState } from './bootstrap.js';
4
4
  import type { ServerOptions } from './bootstrap.js';
5
5
  export declare class RootsManager {
6
- private rootsUpdateTimeout;
6
+ private _debouncedUpdate;
7
7
  private rootDirectories;
8
8
  private allowedDirectoriesState;
9
9
  private clientInitialized;
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { formatUnknownErrorMessage } from '../lib/errors.js';
5
5
  import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
6
6
  import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
7
- import { isRecord } from '../lib/utils.js';
7
+ import { debounce, isRecord } from '../lib/utils.js';
8
8
  import { logToMcp } from './bootstrap.js';
9
9
  const ROOTS_TIMEOUT_MS = 5000;
10
10
  const ROOTS_DEBOUNCE_MS = 100;
@@ -81,7 +81,7 @@ async function filterRootsWithinBaseline(roots, baseline, signal) {
81
81
  });
82
82
  }
83
83
  export class RootsManager {
84
- rootsUpdateTimeout;
84
+ _debouncedUpdate;
85
85
  rootDirectories = [];
86
86
  allowedDirectoriesState = {
87
87
  primary: [],
@@ -102,9 +102,9 @@ export class RootsManager {
102
102
  return this.clientInitialized;
103
103
  }
104
104
  destroy() {
105
- if (this.rootsUpdateTimeout) {
106
- clearTimeout(this.rootsUpdateTimeout);
107
- this.rootsUpdateTimeout = undefined;
105
+ if (this._debouncedUpdate) {
106
+ this._debouncedUpdate.cancel();
107
+ this._debouncedUpdate = undefined;
108
108
  }
109
109
  }
110
110
  getAllowedDirectoriesState() {
@@ -149,15 +149,10 @@ export class RootsManager {
149
149
  }
150
150
  }
151
151
  scheduleRootsUpdate(server) {
152
- if (this.rootsUpdateTimeout) {
153
- this.rootsUpdateTimeout.refresh();
154
- return;
155
- }
156
- this.rootsUpdateTimeout = setTimeout(() => {
157
- this.rootsUpdateTimeout = undefined;
158
- void this.updateRootsFromClient(server);
152
+ this._debouncedUpdate ??= debounce((s) => {
153
+ void this.updateRootsFromClient(s);
159
154
  }, ROOTS_DEBOUNCE_MS);
160
- this.rootsUpdateTimeout.unref();
155
+ this._debouncedUpdate(server);
161
156
  }
162
157
  logMissingDirectories(server) {
163
158
  if (this.options.allowCwd) {
@@ -1,9 +1,9 @@
1
1
  import * as path from 'node:path';
2
2
  import { readFile, stat } from 'node:fs/promises';
3
3
  import { applyPatch, parsePatch } from 'diff';
4
- import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
4
+ import { MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY } from '../lib/constants.js';
5
5
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
6
- import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
6
+ import { atomicWriteFile, processInParallel, withAbort, } from '../lib/fs-helpers.js';
7
7
  import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
8
8
  import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
9
9
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -82,29 +82,31 @@ async function applyDiff(filePath, diff, options, signal) {
82
82
  }
83
83
  async function processMultiFilePatch(basePath, parsed, options, signal) {
84
84
  const validBase = await validateExistingPath(basePath, signal);
85
- const promises = parsed.map(async (diff) => {
85
+ const promises = parsed.map((diff) => {
86
86
  const fileName = extractPatchTargetPath(diff);
87
87
  if (!fileName) {
88
- return {
88
+ return () => Promise.resolve({
89
89
  path: '<unknown>',
90
90
  applied: false,
91
91
  error: 'Missing file name in patch header',
92
- };
92
+ });
93
93
  }
94
94
  const filePath = path.resolve(validBase, fileName);
95
- try {
96
- const result = await applyDiff(filePath, diff, options, signal);
97
- return { ...result, path: fileName };
98
- }
99
- catch (error) {
100
- return {
101
- path: fileName,
102
- applied: false,
103
- error: formatUnknownErrorMessage(error),
104
- };
105
- }
95
+ return async () => {
96
+ try {
97
+ const result = await applyDiff(filePath, diff, options, signal);
98
+ return { ...result, path: fileName };
99
+ }
100
+ catch (error) {
101
+ return {
102
+ path: fileName,
103
+ applied: false,
104
+ error: formatUnknownErrorMessage(error),
105
+ };
106
+ }
107
+ };
106
108
  });
107
- const results = await Promise.all(promises);
109
+ const { results } = await processInParallel(promises, (task) => task(), PARALLEL_CONCURRENCY, signal);
108
110
  const totals = results.reduce((acc, r) => {
109
111
  if (r.applied) {
110
112
  acc.applied++;
@@ -40,24 +40,34 @@ function getLineNumberAtIndex(str, maxIndex = str.length) {
40
40
  function countLines(str) {
41
41
  return getLineNumberAtIndex(str);
42
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;
50
- }
51
- else if (part.removed) {
52
- linesRemoved += part.count;
53
- }
54
- }
55
- return { linesAdded, linesRemoved };
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
+ });
56
61
  }
57
- function findEditMatch(content, oldText, ignoreWhitespace) {
62
+ function findEditMatch(content, oldText, ignoreWhitespace, regexCache) {
58
63
  if (ignoreWhitespace) {
59
64
  const pattern = escapeRegExp(oldText).replace(/\s+/g, '\\s+');
60
- const regex = new RE2(pattern);
65
+ let regex = regexCache?.get(pattern);
66
+ if (!regex) {
67
+ regex = new RE2(pattern);
68
+ if (regexCache)
69
+ regexCache.set(pattern, regex);
70
+ }
61
71
  const match = regex.exec(content);
62
72
  if (!match) {
63
73
  return undefined;
@@ -109,9 +119,9 @@ function buildStructuredEditOutput(validPath, result) {
109
119
  ...(result.lineRange ? { lineRange: result.lineRange } : {}),
110
120
  };
111
121
  }
112
- function finalizeEditResult(originalContent, updatedContent, appliedEdits, unmatchedEdits, lineRange) {
122
+ async function finalizeEditResult(originalContent, updatedContent, appliedEdits, unmatchedEdits, lineRange) {
113
123
  const { linesAdded, linesRemoved } = appliedEdits > 0
114
- ? computeDiffStats(originalContent, updatedContent)
124
+ ? await computeDiffStats(originalContent, updatedContent)
115
125
  : { linesAdded: 0, linesRemoved: 0 };
116
126
  return {
117
127
  content: updatedContent,
@@ -122,9 +132,15 @@ function finalizeEditResult(originalContent, updatedContent, appliedEdits, unmat
122
132
  ...(lineRange ? { lineRange } : {}),
123
133
  };
124
134
  }
125
- function buildDiff(validPath, original, modified) {
135
+ async function buildDiff(validPath, original, modified) {
126
136
  const fileName = basename(validPath);
127
- return createTwoFilesPatch(fileName, fileName, original, modified, 'Original', 'Modified');
137
+ return new Promise((resolve) => {
138
+ createTwoFilesPatch(fileName, fileName, original, modified, 'Original', 'Modified', {
139
+ callback: (res) => {
140
+ resolve(res ?? '');
141
+ },
142
+ });
143
+ });
128
144
  }
129
145
  function formatUnmatchedEditsNote(unmatchedEdits) {
130
146
  if (unmatchedEdits.length === 0) {
@@ -169,15 +185,16 @@ function buildEditCompletionMessage(args, result) {
169
185
  const added = structuredContent.linesAdded ?? 0;
170
186
  const removed = structuredContent.linesRemoved ?? 0;
171
187
  const dry = args.dryRun ? 'dry run ' : '';
172
- return `🛠 edit: ${name} • ${dry}+${added} -${removed}`;
188
+ return `🛠 edit: ${name} • ${dry} +${added} -${removed}`;
173
189
  }
174
- function applyEdits(content, edits, ignoreWhitespace) {
190
+ async function applyEdits(content, edits, ignoreWhitespace) {
175
191
  let newContent = content;
176
192
  let appliedEdits = 0;
177
193
  const unmatchedEdits = [];
178
194
  let lineRange;
195
+ const regexCache = ignoreWhitespace ? new Map() : undefined;
179
196
  for (const edit of edits) {
180
- const match = findEditMatch(newContent, edit.oldText, ignoreWhitespace);
197
+ const match = findEditMatch(newContent, edit.oldText, ignoreWhitespace, regexCache);
181
198
  if (!match) {
182
199
  unmatchedEdits.push(edit.oldText);
183
200
  continue;
@@ -190,11 +207,11 @@ function applyEdits(content, edits, ignoreWhitespace) {
190
207
  }
191
208
  export async function handleEditFile(args, signal) {
192
209
  const { validPath, content } = await loadEditableFile(args.path, signal);
193
- const editResult = applyEdits(content, args.edits, args.ignoreWhitespace);
210
+ const editResult = await applyEdits(content, args.edits, args.ignoreWhitespace);
194
211
  const structured = buildStructuredEditOutput(validPath, editResult);
195
212
  if (args.dryRun) {
196
213
  if (editResult.appliedEdits > 0) {
197
- structured.diff = buildDiff(validPath, content, editResult.content);
214
+ structured.diff = await buildDiff(validPath, content, editResult.content);
198
215
  }
199
216
  return buildToolResponse(`Dry run complete. ${editResult.appliedEdits} edits would be applied.`, structured);
200
217
  }
@@ -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,9 +1,10 @@
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';
5
6
  import { searchContent } from '../lib/file-operations/search.js';
6
- import { formatOperationSummary, joinLines } from '../config.js';
7
+ import { formatOperationSummary } from '../config.js';
7
8
  import { SearchContentInputSchema, SearchContentOutputSchema, } from '../schemas.js';
8
9
  import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
9
10
  import { registerToolTaskIfAvailable } from './task-support.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)
@@ -157,12 +173,13 @@ const SearchResponseBuilder = {
157
173
  });
158
174
  },
159
175
  buildMatchList(heading, matches) {
160
- const lines = [heading];
176
+ if (matches.length === 0)
177
+ return heading;
178
+ const parts = [heading];
161
179
  for (const match of matches) {
162
- const lineNum = String(match.line).padStart(4);
163
- lines.push(` ${match.relativeFile}:${lineNum}: ${match.content}`);
180
+ parts.push(`\n ${match.relativeFile}:${String(match.line).padStart(4)}: ${match.content}`);
164
181
  }
165
- return joinLines(lines);
182
+ return parts.join('');
166
183
  },
167
184
  resolveTruncatedReason(summary) {
168
185
  if (summary.stoppedReason === 'timeout')
@@ -195,6 +212,7 @@ const SearchResponseBuilder = {
195
212
  const SearchExecutor = {
196
213
  async run(args, basePath, signal, onProgress) {
197
214
  const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
215
+ const { timerStartName, timerEndName, metricName } = createSearchMetricNames();
198
216
  const options = {
199
217
  includeHidden: args.includeHidden,
200
218
  excludePatterns,
@@ -208,6 +226,7 @@ const SearchExecutor = {
208
226
  ...(signal ? { signal } : {}),
209
227
  ...(onProgress ? { onProgress } : {}),
210
228
  };
229
+ performance.mark(timerStartName);
211
230
  try {
212
231
  return await searchContent(basePath, args.pattern, options);
213
232
  }
@@ -217,6 +236,13 @@ const SearchExecutor = {
217
236
  }
218
237
  throw error;
219
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
+ }
220
246
  },
221
247
  createMatcher(args) {
222
248
  if (!args.isRegex)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.12.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",