@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.
- package/dist/lib/utils.d.ts +4 -0
- package/dist/lib/utils.js +26 -0
- package/dist/server/roots-manager.d.ts +1 -1
- package/dist/server/roots-manager.js +8 -13
- package/dist/tools/apply-patch.js +19 -17
- package/dist/tools/edit-file.js +40 -23
- package/dist/tools/search-content.js +25 -5
- package/package.json +1 -1
package/dist/lib/utils.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
106
|
-
|
|
107
|
-
this.
|
|
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
|
-
|
|
153
|
-
this.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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++;
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
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.
|
|
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",
|