@j0hanz/filesystem-mcp 1.12.0 → 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,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) {
@@ -171,13 +187,14 @@ function buildEditCompletionMessage(args, result) {
171
187
  const dry = args.dryRun ? 'dry run ' : '';
172
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
  }
@@ -3,7 +3,7 @@ import RE2 from 're2';
3
3
  import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
4
4
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
5
5
  import { searchContent } from '../lib/file-operations/search.js';
6
- import { formatOperationSummary, joinLines } from '../config.js';
6
+ import { formatOperationSummary } from '../config.js';
7
7
  import { SearchContentInputSchema, SearchContentOutputSchema, } from '../schemas.js';
8
8
  import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
9
9
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -157,12 +157,32 @@ const SearchResponseBuilder = {
157
157
  });
158
158
  },
159
159
  buildMatchList(heading, matches) {
160
- const lines = [heading];
160
+ if (matches.length === 0)
161
+ 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');
165
+ 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');
174
+ }
175
+ const buf = Buffer.allocUnsafe(totalBytes);
176
+ let offset = buf.write(heading, 0, 'utf8');
161
177
  for (const match of matches) {
162
- const lineNum = String(match.line).padStart(4);
163
- lines.push(` ${match.relativeFile}:${lineNum}: ${match.content}`);
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');
164
184
  }
165
- return joinLines(lines);
185
+ return buf.toString('utf8', 0, offset);
166
186
  },
167
187
  resolveTruncatedReason(summary) {
168
188
  if (summary.stoppedReason === 'timeout')
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.0",
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",