@j0hanz/filesystem-mcp 1.9.0 → 1.10.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/README.md +15 -15
- package/dist/completions.js +140 -115
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/file-operations/metadata.d.ts +5 -1
- package/dist/lib/file-operations/metadata.js +14 -1
- package/dist/lib/file-operations/search.d.ts +7 -5
- package/dist/lib/file-operations/search.js +64 -32
- package/dist/lib/fs-helpers.d.ts +3 -1
- package/dist/lib/fs-helpers.js +63 -0
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.js +119 -64
- package/dist/lib/resource-store.d.ts +2 -0
- package/dist/lib/resource-store.js +58 -17
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +51 -0
- package/dist/resources/generated-instructions.js +36 -9
- package/dist/resources/tool-catalog.js +30 -7
- package/dist/resources/tool-info.d.ts +4 -0
- package/dist/resources/tool-info.js +21 -3
- package/dist/resources/workflows.js +17 -5
- package/dist/schemas.d.ts +47 -12
- package/dist/schemas.js +75 -18
- package/dist/server/bootstrap.js +103 -91
- package/dist/server/roots-manager.d.ts +3 -0
- package/dist/server/roots-manager.js +15 -3
- package/dist/tools/apply-patch.js +135 -31
- package/dist/tools/calculate-hash.js +13 -8
- package/dist/tools/create-directory.js +14 -3
- package/dist/tools/delete-file.js +1 -0
- package/dist/tools/diff-files.js +26 -8
- package/dist/tools/edit-file.js +11 -8
- package/dist/tools/list-directory.js +1 -6
- package/dist/tools/move-file.js +39 -7
- package/dist/tools/read-multiple.js +9 -1
- package/dist/tools/read.js +38 -6
- package/dist/tools/replace-in-files.js +72 -25
- package/dist/tools/roots.js +1 -0
- package/dist/tools/search-content.js +76 -48
- package/dist/tools/search-files.js +6 -7
- package/dist/tools/shared.d.ts +2 -1
- package/dist/tools/shared.js +36 -20
- package/dist/tools/stat-many.js +1 -1
- package/dist/tools/stat.js +4 -0
- package/dist/tools/task-support.js +4 -12
- package/dist/tools/tree.js +4 -0
- package/dist/tools/write-file.js +4 -2
- package/package.json +17 -8
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fsp from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
3
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
5
|
import { parentPort, threadId, Worker, workerData } from 'node:worker_threads';
|
|
5
6
|
import RE2 from 're2';
|
|
@@ -17,6 +18,7 @@ export const MatcherOptionsSchema = z.strictObject({
|
|
|
17
18
|
caseSensitive: z.boolean(),
|
|
18
19
|
wholeWord: z.boolean(),
|
|
19
20
|
isLiteral: z.boolean(),
|
|
21
|
+
multiline: z.boolean(),
|
|
20
22
|
});
|
|
21
23
|
function countRegexLineMatches(regex, line) {
|
|
22
24
|
regex.lastIndex = 0;
|
|
@@ -67,8 +69,11 @@ function buildLiteralMatcher(pattern, options) {
|
|
|
67
69
|
return count;
|
|
68
70
|
};
|
|
69
71
|
}
|
|
70
|
-
function buildRegexMatcher(final, caseSensitive) {
|
|
71
|
-
|
|
72
|
+
function buildRegexMatcher(final, caseSensitive, multiline) {
|
|
73
|
+
let flags = caseSensitive ? 'g' : 'gi';
|
|
74
|
+
if (multiline)
|
|
75
|
+
flags += 'm';
|
|
76
|
+
const regex = new RE2(final, flags);
|
|
72
77
|
return (line) => countRegexLineMatches(regex, line);
|
|
73
78
|
}
|
|
74
79
|
export function buildMatcher(pattern, options) {
|
|
@@ -80,7 +85,7 @@ export function buildMatcher(pattern, options) {
|
|
|
80
85
|
}
|
|
81
86
|
const final = buildRegexPattern(pattern, options);
|
|
82
87
|
validatePattern(pattern, options); // Re-validate to be safe
|
|
83
|
-
return buildRegexMatcher(final, options.caseSensitive);
|
|
88
|
+
return buildRegexMatcher(final, options.caseSensitive, options.multiline);
|
|
84
89
|
}
|
|
85
90
|
// --- Configuration & Schemas ---
|
|
86
91
|
const SEARCH_CONTENT_MAX_RESULTS = 500;
|
|
@@ -88,14 +93,15 @@ const SearchOptionsSchema = z.strictObject({
|
|
|
88
93
|
filePattern: z.string().min(1),
|
|
89
94
|
excludePatterns: z.array(z.string()),
|
|
90
95
|
caseSensitive: z.boolean(),
|
|
91
|
-
maxResults: z.
|
|
92
|
-
maxFileSize: z.
|
|
93
|
-
maxFilesScanned: z.
|
|
94
|
-
timeoutMs: z.
|
|
96
|
+
maxResults: z.int().min(0),
|
|
97
|
+
maxFileSize: z.int().min(0),
|
|
98
|
+
maxFilesScanned: z.int().min(0),
|
|
99
|
+
timeoutMs: z.int().min(0),
|
|
95
100
|
skipBinary: z.boolean(),
|
|
96
|
-
contextLines: z.
|
|
101
|
+
contextLines: z.int().min(0),
|
|
97
102
|
wholeWord: z.boolean(),
|
|
98
103
|
isLiteral: z.boolean(),
|
|
104
|
+
multiline: z.boolean(),
|
|
99
105
|
includeHidden: z.boolean(),
|
|
100
106
|
baseNameMatch: z.boolean(),
|
|
101
107
|
caseSensitiveFileMatch: z.boolean(),
|
|
@@ -112,6 +118,7 @@ const DEFAULTS = {
|
|
|
112
118
|
contextLines: 0,
|
|
113
119
|
wholeWord: false,
|
|
114
120
|
isLiteral: true,
|
|
121
|
+
multiline: false,
|
|
115
122
|
includeHidden: false,
|
|
116
123
|
baseNameMatch: false,
|
|
117
124
|
caseSensitiveFileMatch: true,
|
|
@@ -297,6 +304,14 @@ function buildScanFileOptions(opts) {
|
|
|
297
304
|
contextLines: opts.contextLines,
|
|
298
305
|
};
|
|
299
306
|
}
|
|
307
|
+
function buildMatcherOptions(opts) {
|
|
308
|
+
return {
|
|
309
|
+
caseSensitive: opts.caseSensitive,
|
|
310
|
+
wholeWord: opts.wholeWord,
|
|
311
|
+
isLiteral: opts.isLiteral,
|
|
312
|
+
multiline: opts.multiline,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
300
315
|
function applyScanOutcome(summary, outcome) {
|
|
301
316
|
if (outcome.matched)
|
|
302
317
|
summary.filesMatched++;
|
|
@@ -344,6 +359,7 @@ const isSourceContext = currentDir.endsWith('src\\lib\\file-operations') ||
|
|
|
344
359
|
currentDir.endsWith('src/lib/file-operations');
|
|
345
360
|
const WORKER_SCRIPT_PATH = path.join(currentDir, isSourceContext ? 'search-worker.ts' : 'search-worker.js');
|
|
346
361
|
const WORKER_SCRIPT_URL = pathToFileURL(WORKER_SCRIPT_PATH);
|
|
362
|
+
const hasWorkerScript = existsSync(WORKER_SCRIPT_PATH);
|
|
347
363
|
class SearchWorkerPool {
|
|
348
364
|
size;
|
|
349
365
|
debug;
|
|
@@ -475,7 +491,7 @@ class SearchWorkerPool {
|
|
|
475
491
|
}
|
|
476
492
|
}
|
|
477
493
|
function isWorkerPoolAvailable() {
|
|
478
|
-
return !isSourceContext;
|
|
494
|
+
return !isSourceContext && hasWorkerScript;
|
|
479
495
|
}
|
|
480
496
|
function shouldUseWorkers() {
|
|
481
497
|
return isWorkerPoolAvailable() && SEARCH_WORKERS >= 2;
|
|
@@ -516,8 +532,8 @@ async function executeSequential(files, pattern, opts, signal, summary) {
|
|
|
516
532
|
}
|
|
517
533
|
return matches;
|
|
518
534
|
}
|
|
519
|
-
|
|
520
|
-
|
|
535
|
+
async function fillWorkerPool(context) {
|
|
536
|
+
const { pool, pending, iterator, pattern, matcherOpts, scanOpts, maxResults, currentMatches, summary, } = context;
|
|
521
537
|
while (pending.size < SEARCH_WORKERS) {
|
|
522
538
|
const result = await iterator.next();
|
|
523
539
|
if (result.done)
|
|
@@ -574,15 +590,20 @@ async function waitForWinner(pending) {
|
|
|
574
590
|
}
|
|
575
591
|
return Promise.race(raceCandidates);
|
|
576
592
|
}
|
|
593
|
+
function updateParallelTruncation(summary, signal, matchesLength, maxResults) {
|
|
594
|
+
if (signal.aborted) {
|
|
595
|
+
markTruncated(summary, 'timeout');
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (matchesLength >= maxResults) {
|
|
599
|
+
markTruncated(summary, 'maxResults');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
577
602
|
async function executeParallel(files, pattern, opts, signal, summary) {
|
|
578
603
|
const pool = getPool();
|
|
579
604
|
const matches = [];
|
|
580
605
|
const scanOpts = buildScanFileOptions(opts);
|
|
581
|
-
const matcherOpts =
|
|
582
|
-
caseSensitive: opts.caseSensitive,
|
|
583
|
-
wholeWord: opts.wholeWord,
|
|
584
|
-
isLiteral: opts.isLiteral,
|
|
585
|
-
};
|
|
606
|
+
const matcherOpts = buildMatcherOptions(opts);
|
|
586
607
|
const pending = new Set();
|
|
587
608
|
const iterator = files[Symbol.asyncIterator]();
|
|
588
609
|
let exhausted = false;
|
|
@@ -598,7 +619,17 @@ async function executeParallel(files, pattern, opts, signal, summary) {
|
|
|
598
619
|
break;
|
|
599
620
|
}
|
|
600
621
|
if (!exhausted) {
|
|
601
|
-
exhausted = await fillWorkerPool(
|
|
622
|
+
exhausted = await fillWorkerPool({
|
|
623
|
+
pool,
|
|
624
|
+
pending,
|
|
625
|
+
iterator,
|
|
626
|
+
pattern,
|
|
627
|
+
matcherOpts,
|
|
628
|
+
scanOpts,
|
|
629
|
+
maxResults: opts.maxResults,
|
|
630
|
+
currentMatches: matches.length,
|
|
631
|
+
summary,
|
|
632
|
+
});
|
|
602
633
|
}
|
|
603
634
|
if (pending.size === 0 && exhausted) {
|
|
604
635
|
break;
|
|
@@ -617,13 +648,7 @@ async function executeParallel(files, pattern, opts, signal, summary) {
|
|
|
617
648
|
if (iterator.return)
|
|
618
649
|
await iterator.return();
|
|
619
650
|
}
|
|
620
|
-
|
|
621
|
-
if (signal.aborted) {
|
|
622
|
-
markTruncated(summary, 'timeout');
|
|
623
|
-
}
|
|
624
|
-
else if (matches.length >= opts.maxResults) {
|
|
625
|
-
markTruncated(summary, 'maxResults');
|
|
626
|
-
}
|
|
651
|
+
updateParallelTruncation(summary, signal, matches.length, opts.maxResults);
|
|
627
652
|
return matches;
|
|
628
653
|
}
|
|
629
654
|
// --- Entry Points ---
|
|
@@ -702,11 +727,7 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
|
|
|
702
727
|
summary.stoppedReason = 'maxFiles';
|
|
703
728
|
}
|
|
704
729
|
}
|
|
705
|
-
const matcherOpts =
|
|
706
|
-
caseSensitive: opts.caseSensitive,
|
|
707
|
-
wholeWord: opts.wholeWord,
|
|
708
|
-
isLiteral: opts.isLiteral,
|
|
709
|
-
};
|
|
730
|
+
const matcherOpts = buildMatcherOptions(opts);
|
|
710
731
|
validatePattern(pattern, matcherOpts);
|
|
711
732
|
const matches = shouldUseWorkers()
|
|
712
733
|
? await executeParallel(countingStream(), pattern, opts, signal, summary)
|
|
@@ -841,7 +862,8 @@ function handleEntry(entry, entryType, needsStats, normalized, state) {
|
|
|
841
862
|
state.stoppedReason = 'maxResults';
|
|
842
863
|
}
|
|
843
864
|
}
|
|
844
|
-
async function collectFromStream(stream,
|
|
865
|
+
async function collectFromStream(stream, signal, context) {
|
|
866
|
+
const { root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, accessDeps, onProgress, } = context;
|
|
845
867
|
for await (const entry of stream) {
|
|
846
868
|
if (shouldStopCollecting(state, normalized, signal))
|
|
847
869
|
break;
|
|
@@ -891,7 +913,16 @@ async function collectSearchResults(root, pattern, excludePatterns, normalized,
|
|
|
891
913
|
const gitignoreMatcher = normalized.respectGitignore
|
|
892
914
|
? await loadRootGitignore(root, signal)
|
|
893
915
|
: null;
|
|
894
|
-
await collectFromStream(stream,
|
|
916
|
+
await collectFromStream(stream, signal, {
|
|
917
|
+
root,
|
|
918
|
+
rootDirectories,
|
|
919
|
+
gitignoreMatcher,
|
|
920
|
+
normalized,
|
|
921
|
+
needsStats,
|
|
922
|
+
state,
|
|
923
|
+
accessDeps,
|
|
924
|
+
...(onProgress ? { onProgress } : {}),
|
|
925
|
+
});
|
|
895
926
|
return buildCollectResult(state);
|
|
896
927
|
}
|
|
897
928
|
function buildSearchSummary(results, filesScanned, truncated, stoppedReason, skippedInaccessible) {
|
|
@@ -956,7 +987,8 @@ function getMatcherCacheKey(pattern, options) {
|
|
|
956
987
|
const cs = options.caseSensitive ? '1' : '0';
|
|
957
988
|
const ww = options.wholeWord ? '1' : '0';
|
|
958
989
|
const lit = options.isLiteral ? '1' : '0';
|
|
959
|
-
|
|
990
|
+
const ml = options.multiline ? '1' : '0';
|
|
991
|
+
return `${pattern}|${cs}|${ww}|${lit}|${ml}`;
|
|
960
992
|
}
|
|
961
993
|
function getCachedMatcher(pattern, options) {
|
|
962
994
|
const key = getMatcherCacheKey(pattern, options);
|
package/dist/lib/fs-helpers.d.ts
CHANGED
|
@@ -19,11 +19,12 @@ export declare function processInParallel<T, R>(items: T[], processor: (item: T)
|
|
|
19
19
|
export declare function getFileType(stats: Stats): FileType;
|
|
20
20
|
export declare function isHidden(name: string): boolean;
|
|
21
21
|
export declare function isProbablyBinary(filePath: string, existingHandle?: fsp.FileHandle, signal?: AbortSignal): Promise<boolean>;
|
|
22
|
-
type ReadMode = 'head' | 'full' | 'range';
|
|
22
|
+
type ReadMode = 'head' | 'full' | 'range' | 'tail';
|
|
23
23
|
interface ReadFileOptions {
|
|
24
24
|
encoding?: BufferEncoding;
|
|
25
25
|
maxSize?: number;
|
|
26
26
|
head?: number;
|
|
27
|
+
tail?: number;
|
|
27
28
|
startLine?: number;
|
|
28
29
|
endLine?: number;
|
|
29
30
|
skipBinary?: boolean;
|
|
@@ -36,6 +37,7 @@ interface ReadFileResult {
|
|
|
36
37
|
totalLines?: number;
|
|
37
38
|
readMode: ReadMode;
|
|
38
39
|
head?: number;
|
|
40
|
+
tail?: number;
|
|
39
41
|
startLine?: number;
|
|
40
42
|
endLine?: number;
|
|
41
43
|
linesRead?: number;
|
package/dist/lib/fs-helpers.js
CHANGED
|
@@ -241,15 +241,20 @@ function isBinarySlice(slice) {
|
|
|
241
241
|
}
|
|
242
242
|
function validateReadOptions(options) {
|
|
243
243
|
const hasHead = options.head !== undefined;
|
|
244
|
+
const hasTail = options.tail !== undefined;
|
|
244
245
|
const hasStart = options.startLine !== undefined;
|
|
245
246
|
const hasEnd = options.endLine !== undefined;
|
|
246
247
|
assertPositiveSafeIntegerOption('maxSize', options.maxSize, 'maxSize must be at least 1');
|
|
247
248
|
assertPositiveSafeIntegerOption('head', options.head, 'head must be at least 1');
|
|
249
|
+
assertPositiveSafeIntegerOption('tail', options.tail, 'tail must be at least 1');
|
|
248
250
|
assertPositiveSafeIntegerOption('startLine', options.startLine, 'startLine must be at least 1');
|
|
249
251
|
assertPositiveSafeIntegerOption('endLine', options.endLine, 'endLine must be at least 1');
|
|
250
252
|
if (hasHead && (hasStart || hasEnd)) {
|
|
251
253
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'head cannot be used together with startLine/endLine');
|
|
252
254
|
}
|
|
255
|
+
if (hasTail && (hasHead || hasStart || hasEnd)) {
|
|
256
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, 'tail cannot be used together with head/startLine/endLine');
|
|
257
|
+
}
|
|
253
258
|
if (hasEnd && !hasStart) {
|
|
254
259
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'endLine requires startLine');
|
|
255
260
|
}
|
|
@@ -269,6 +274,9 @@ function normalizeOptions(options) {
|
|
|
269
274
|
if (options.head !== undefined) {
|
|
270
275
|
normalized.head = options.head;
|
|
271
276
|
}
|
|
277
|
+
if (options.tail !== undefined) {
|
|
278
|
+
normalized.tail = options.tail;
|
|
279
|
+
}
|
|
272
280
|
if (options.startLine !== undefined) {
|
|
273
281
|
normalized.startLine = options.startLine;
|
|
274
282
|
}
|
|
@@ -298,6 +306,8 @@ function buildReadContentOptions(normalized) {
|
|
|
298
306
|
function resolveReadMode(options) {
|
|
299
307
|
if (options.head !== undefined)
|
|
300
308
|
return 'head';
|
|
309
|
+
if (options.tail !== undefined)
|
|
310
|
+
return 'tail';
|
|
301
311
|
if (options.startLine !== undefined)
|
|
302
312
|
return 'range';
|
|
303
313
|
return 'full';
|
|
@@ -446,6 +456,37 @@ async function readRangeContent(handle, startLine, endLine, options) {
|
|
|
446
456
|
hasMoreLines: effectiveHasMoreLines,
|
|
447
457
|
};
|
|
448
458
|
}
|
|
459
|
+
async function readTailContent(handle, tail, options) {
|
|
460
|
+
assertNotAborted(options.signal);
|
|
461
|
+
const ring = new Array(tail);
|
|
462
|
+
let totalLines = 0;
|
|
463
|
+
let head = 0;
|
|
464
|
+
let size = 0;
|
|
465
|
+
for await (const line of handle.readLines({
|
|
466
|
+
encoding: options.encoding,
|
|
467
|
+
signal: options.signal,
|
|
468
|
+
})) {
|
|
469
|
+
ring[head] = line;
|
|
470
|
+
head = (head + 1) % tail;
|
|
471
|
+
if (size < tail)
|
|
472
|
+
size++;
|
|
473
|
+
totalLines++;
|
|
474
|
+
}
|
|
475
|
+
const lines = new Array(size);
|
|
476
|
+
const start = size < tail ? 0 : head;
|
|
477
|
+
for (let i = 0; i < size; i++) {
|
|
478
|
+
lines[i] = ring[(start + i) % tail] ?? '';
|
|
479
|
+
}
|
|
480
|
+
const content = lines.join('\n');
|
|
481
|
+
const linesRead = countLines(content);
|
|
482
|
+
const hasMoreLines = totalLines > tail;
|
|
483
|
+
return {
|
|
484
|
+
content,
|
|
485
|
+
truncated: hasMoreLines,
|
|
486
|
+
linesRead,
|
|
487
|
+
hasMoreLines,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
449
490
|
async function readFullContent(handle, encoding, maxSize, requestedPath, signal) {
|
|
450
491
|
const buffer = await readFileBufferWithLimit(handle, maxSize, requestedPath, signal);
|
|
451
492
|
const content = buffer.toString(encoding);
|
|
@@ -501,6 +542,17 @@ function buildFullResult(validPath, content, totalLines) {
|
|
|
501
542
|
hasMoreLines: false,
|
|
502
543
|
};
|
|
503
544
|
}
|
|
545
|
+
function buildTailResult(validPath, content, truncated, tail, linesRead, hasMoreLines) {
|
|
546
|
+
return {
|
|
547
|
+
path: validPath,
|
|
548
|
+
content,
|
|
549
|
+
truncated,
|
|
550
|
+
readMode: 'tail',
|
|
551
|
+
tail,
|
|
552
|
+
linesRead,
|
|
553
|
+
hasMoreLines,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
504
556
|
function assertSizeWithinLimit(size, maxSize, filePath) {
|
|
505
557
|
if (size <= maxSize)
|
|
506
558
|
return;
|
|
@@ -526,11 +578,22 @@ async function readFullResult(handle, validPath, filePath, stats, normalized) {
|
|
|
526
578
|
const { content, totalLines } = await readFullContent(handle, normalized.encoding, normalized.maxSize, filePath, normalized.signal);
|
|
527
579
|
return buildFullResult(validPath, content, totalLines);
|
|
528
580
|
}
|
|
581
|
+
async function readTailResult(handle, validPath, filePath, normalized) {
|
|
582
|
+
if (normalized.tail === undefined) {
|
|
583
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Missing tail option', filePath);
|
|
584
|
+
}
|
|
585
|
+
const readOptions = buildReadContentOptions(normalized);
|
|
586
|
+
const { content, truncated, linesRead, hasMoreLines } = await readTailContent(handle, normalized.tail, readOptions);
|
|
587
|
+
return buildTailResult(validPath, content, truncated, normalized.tail, linesRead, hasMoreLines);
|
|
588
|
+
}
|
|
529
589
|
async function readByMode(handle, validPath, filePath, stats, normalized) {
|
|
530
590
|
const mode = resolveReadMode(normalized);
|
|
531
591
|
if (mode === 'head') {
|
|
532
592
|
return readHeadResult(handle, validPath, filePath, normalized);
|
|
533
593
|
}
|
|
594
|
+
if (mode === 'tail') {
|
|
595
|
+
return readTailResult(handle, validPath, filePath, normalized);
|
|
596
|
+
}
|
|
534
597
|
if (mode === 'range') {
|
|
535
598
|
return readRangeResult(handle, validPath, filePath, normalized);
|
|
536
599
|
}
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { McpError } from './errors.js';
|
|
|
3
3
|
export declare function toPosixPath(value: string): string;
|
|
4
4
|
export declare function isSensitivePath(requestedPath: string, resolvedPath?: string): boolean;
|
|
5
5
|
export declare function assertAllowedFileAccess(requestedPath: string, resolvedPath?: string): void;
|
|
6
|
+
export interface AllowedDirectoriesState {
|
|
7
|
+
primary: string[];
|
|
8
|
+
expanded: string[];
|
|
9
|
+
}
|
|
6
10
|
/**
|
|
7
11
|
* Normalizes any path-like input to an absolute path suitable for comparisons.
|
|
8
12
|
* - Expands "~" home directory shorthand.
|
|
@@ -10,9 +14,13 @@ export declare function assertAllowedFileAccess(requestedPath: string, resolvedP
|
|
|
10
14
|
* - Lowercases Windows drive letter for stable comparisons.
|
|
11
15
|
*/
|
|
12
16
|
export declare function normalizePath(p: string): string;
|
|
17
|
+
export declare function withAllowedDirectoriesState<T>(state: AllowedDirectoriesState, run: () => T): T;
|
|
18
|
+
export declare function getAllowedDirectoriesState(): AllowedDirectoriesState;
|
|
19
|
+
export declare function setAllowedDirectoriesStateResolved(state: AllowedDirectoriesState): void;
|
|
13
20
|
export declare function getAllowedDirectories(): string[];
|
|
14
21
|
export declare function isAllowedDirectoryRoot(normalizedPath: string): boolean;
|
|
15
22
|
export declare function isPathWithinDirectories(normalizedPath: string, allowedDirs: readonly string[]): boolean;
|
|
23
|
+
export declare function resolveAllowedDirectoriesState(dirs: readonly string[], signal?: AbortSignal): Promise<AllowedDirectoriesState>;
|
|
16
24
|
export declare function setAllowedDirectoriesResolved(dirs: readonly string[], signal?: AbortSignal): Promise<void>;
|
|
17
25
|
export declare function getReservedDeviceNameForPath(requestedPath: string): string | undefined;
|
|
18
26
|
export declare function isWindowsDriveRelativePath(requestedPath: string): boolean;
|
package/dist/lib/paths.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
5
|
import { platform } from 'node:os';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { SENSITIVE_FILE_ALLOWLIST, SENSITIVE_FILE_DENYLIST, } from './constants.js';
|
|
@@ -15,12 +16,17 @@ export function toPosixPath(value) {
|
|
|
15
16
|
}
|
|
16
17
|
const IS_WINDOWS = platform() === 'win32';
|
|
17
18
|
const WINDOWS_ABSOLUTE_RE = /^[a-z]:\//iu;
|
|
19
|
+
const HOME_PREFIX_LENGTH = 2;
|
|
20
|
+
const CHAR_CODE_SPACE = 32;
|
|
21
|
+
const CHAR_CODE_DOT = 46;
|
|
18
22
|
function normalizePathForMatch(input) {
|
|
19
23
|
return toPosixPath(path.normalize(input));
|
|
20
24
|
}
|
|
21
25
|
function normalizeForMatch(input) {
|
|
22
26
|
const normalized = normalizePathForMatch(input);
|
|
23
|
-
|
|
27
|
+
// Always lowercase for case-insensitive denylist matching on all platforms.
|
|
28
|
+
// Prevents bypassing `.env` block with `.ENV` on case-sensitive filesystems.
|
|
29
|
+
return normalized.toLowerCase();
|
|
24
30
|
}
|
|
25
31
|
function compilePatternGlobs(normalizedPattern) {
|
|
26
32
|
const globs = new Set([normalizedPattern]);
|
|
@@ -137,16 +143,27 @@ const RESERVED_DEVICE_NAMES = new Set([
|
|
|
137
143
|
'LPT8',
|
|
138
144
|
'LPT9',
|
|
139
145
|
]);
|
|
146
|
+
const allowedDirectoriesContext = new AsyncLocalStorage({
|
|
147
|
+
name: 'filesystem-mcp:allowed-directories',
|
|
148
|
+
});
|
|
140
149
|
function dedupePreserveOrder(items) {
|
|
141
150
|
return [...new Set(items)];
|
|
142
151
|
}
|
|
152
|
+
function cloneAllowedDirectoriesState(state) {
|
|
153
|
+
return {
|
|
154
|
+
primary: [...state.primary],
|
|
155
|
+
expanded: [...state.expanded],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
143
158
|
function expandHome(filepath) {
|
|
144
159
|
if (filepath === '~')
|
|
145
160
|
return HOMEDIR;
|
|
146
161
|
// Accept both "~/" and "~\\" for cross-platform UX.
|
|
147
162
|
if (filepath.startsWith('~/') || filepath.startsWith('~\\')) {
|
|
148
163
|
// Avoid `path.join(HOMEDIR, "/foo")` resetting to the filesystem root.
|
|
149
|
-
const rest = filepath
|
|
164
|
+
const rest = filepath
|
|
165
|
+
.slice(HOME_PREFIX_LENGTH)
|
|
166
|
+
.replace(LEADING_SEPARATORS_RE, '');
|
|
150
167
|
return rest.length === 0 ? HOMEDIR : path.join(HOMEDIR, rest);
|
|
151
168
|
}
|
|
152
169
|
return filepath;
|
|
@@ -164,9 +181,12 @@ export function normalizePath(p) {
|
|
|
164
181
|
}
|
|
165
182
|
return resolved;
|
|
166
183
|
}
|
|
167
|
-
function
|
|
184
|
+
function normalizeCaseForComparison(value) {
|
|
168
185
|
return IS_WINDOWS ? value.toLowerCase() : value;
|
|
169
186
|
}
|
|
187
|
+
function normalizeForComparison(value) {
|
|
188
|
+
return normalizeCaseForComparison(value);
|
|
189
|
+
}
|
|
170
190
|
function rethrowIfAborted(error) {
|
|
171
191
|
if (isAbortError(error))
|
|
172
192
|
throw error;
|
|
@@ -174,11 +194,9 @@ function rethrowIfAborted(error) {
|
|
|
174
194
|
function isSamePath(left, right) {
|
|
175
195
|
if (left === right)
|
|
176
196
|
return true;
|
|
177
|
-
const leftResolved = path.resolve(left);
|
|
178
|
-
const rightResolved = path.resolve(right);
|
|
179
|
-
return
|
|
180
|
-
? leftResolved.toLowerCase() === rightResolved.toLowerCase()
|
|
181
|
-
: leftResolved === rightResolved;
|
|
197
|
+
const leftResolved = normalizeCaseForComparison(path.resolve(left));
|
|
198
|
+
const rightResolved = normalizeCaseForComparison(path.resolve(right));
|
|
199
|
+
return leftResolved === rightResolved;
|
|
182
200
|
}
|
|
183
201
|
function stripTrailingSeparator(normalized) {
|
|
184
202
|
return normalized.length > 1 && normalized.endsWith(PATH_SEPARATOR)
|
|
@@ -218,26 +236,41 @@ function normalizeAllowedDirectories(dirs) {
|
|
|
218
236
|
// single MCP session per process, so this is safe. In HTTP mode all HTTP
|
|
219
237
|
// sessions within the same process share one policy — multi-tenant isolation
|
|
220
238
|
// (different roots per session) requires separate server processes.
|
|
221
|
-
let
|
|
222
|
-
|
|
239
|
+
let defaultAllowedDirectoriesState = {
|
|
240
|
+
primary: [],
|
|
241
|
+
expanded: [],
|
|
242
|
+
};
|
|
223
243
|
function setAllowedDirectoriesState(primary, expanded) {
|
|
224
|
-
|
|
225
|
-
|
|
244
|
+
defaultAllowedDirectoriesState = {
|
|
245
|
+
primary: dedupePreserveOrder(primary),
|
|
246
|
+
expanded: dedupePreserveOrder(expanded),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function getActiveAllowedDirectoriesState() {
|
|
250
|
+
return allowedDirectoriesContext.getStore() ?? defaultAllowedDirectoriesState;
|
|
251
|
+
}
|
|
252
|
+
export function withAllowedDirectoriesState(state, run) {
|
|
253
|
+
return allowedDirectoriesContext.run(cloneAllowedDirectoriesState(state), run);
|
|
254
|
+
}
|
|
255
|
+
export function getAllowedDirectoriesState() {
|
|
256
|
+
return cloneAllowedDirectoriesState(getActiveAllowedDirectoriesState());
|
|
257
|
+
}
|
|
258
|
+
export function setAllowedDirectoriesStateResolved(state) {
|
|
259
|
+
setAllowedDirectoriesState(state.primary, state.expanded);
|
|
226
260
|
}
|
|
227
261
|
export function getAllowedDirectories() {
|
|
228
|
-
return [...
|
|
262
|
+
return [...getActiveAllowedDirectoriesState().expanded];
|
|
229
263
|
}
|
|
230
264
|
export function isAllowedDirectoryRoot(normalizedPath) {
|
|
231
|
-
for (const dir of
|
|
265
|
+
for (const dir of getActiveAllowedDirectoriesState().expanded) {
|
|
232
266
|
if (isSamePath(normalizedPath, dir))
|
|
233
267
|
return true;
|
|
234
268
|
}
|
|
235
269
|
return false;
|
|
236
270
|
}
|
|
237
271
|
function getAllowedDirectoriesForRelativeResolution() {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
: allowedDirectoriesExpanded;
|
|
272
|
+
const state = getActiveAllowedDirectoriesState();
|
|
273
|
+
return state.primary.length > 0 ? state.primary : state.expanded;
|
|
241
274
|
}
|
|
242
275
|
function isPathInsideDirectory(normalizedDirectory, normalizedCandidate) {
|
|
243
276
|
const root = normalizeForComparison(normalizedDirectory);
|
|
@@ -286,10 +319,14 @@ async function expandAllowedDirectories(primaryDirs, signal) {
|
|
|
286
319
|
}
|
|
287
320
|
return dedupePreserveOrder(expanded);
|
|
288
321
|
}
|
|
289
|
-
export async function
|
|
322
|
+
export async function resolveAllowedDirectoriesState(dirs, signal) {
|
|
290
323
|
const primary = normalizeAllowedDirectories(dirs);
|
|
291
324
|
const expanded = await expandAllowedDirectories(primary, signal);
|
|
292
|
-
|
|
325
|
+
return { primary, expanded };
|
|
326
|
+
}
|
|
327
|
+
export async function setAllowedDirectoriesResolved(dirs, signal) {
|
|
328
|
+
const state = await resolveAllowedDirectoriesState(dirs, signal);
|
|
329
|
+
setAllowedDirectoriesStateResolved(state);
|
|
293
330
|
}
|
|
294
331
|
function ensureNonEmptyPath(requestedPath) {
|
|
295
332
|
if (!requestedPath || requestedPath.trim().length === 0) {
|
|
@@ -306,7 +343,7 @@ function getReservedDeviceName(segment) {
|
|
|
306
343
|
let end = segment.length;
|
|
307
344
|
while (end > 0) {
|
|
308
345
|
const c = segment.charCodeAt(end - 1);
|
|
309
|
-
if (c ===
|
|
346
|
+
if (c === CHAR_CODE_SPACE || c === CHAR_CODE_DOT)
|
|
310
347
|
end--; // space or dot
|
|
311
348
|
else
|
|
312
349
|
break;
|
|
@@ -440,7 +477,7 @@ async function resolveRealPathOrThrow(options) {
|
|
|
440
477
|
throw toMcpError(requestedPath, error);
|
|
441
478
|
}
|
|
442
479
|
}
|
|
443
|
-
|
|
480
|
+
function preparePathAccess(requestedPath) {
|
|
444
481
|
const normalizedRequested = validateRequestedPath(requestedPath);
|
|
445
482
|
const allowedDirs = getAllowedDirectories();
|
|
446
483
|
ensureWithinAllowedDirectories({
|
|
@@ -449,15 +486,60 @@ async function validateExistingPathDetailsInternal(requestedPath, signal) {
|
|
|
449
486
|
allowedDirs,
|
|
450
487
|
details: { normalizedPath: normalizedRequested },
|
|
451
488
|
});
|
|
489
|
+
return { allowedDirs, normalizedRequested };
|
|
490
|
+
}
|
|
491
|
+
function ensureResolvedPathAllowed(options) {
|
|
492
|
+
const { requestedPath, resolvedPath, normalizedResolved, allowedDirs } = options;
|
|
493
|
+
if (isPathWithinDirectories(normalizedResolved, allowedDirs))
|
|
494
|
+
return;
|
|
495
|
+
throw toAccessDeniedWithHint(requestedPath, resolvedPath, normalizedResolved);
|
|
496
|
+
}
|
|
497
|
+
async function statPathOrThrow(requestedPath, resolvedPath, signal) {
|
|
498
|
+
try {
|
|
499
|
+
assertNotAborted(signal);
|
|
500
|
+
return await withAbort(fs.stat(resolvedPath), signal);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
rethrowIfAborted(error);
|
|
504
|
+
throw toMcpError(requestedPath, error);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function resolveNearestExistingRealPathOrThrow(options) {
|
|
508
|
+
const { requestedPath, startPath, signal } = options;
|
|
509
|
+
let current = startPath;
|
|
510
|
+
for (;;) {
|
|
511
|
+
try {
|
|
512
|
+
assertNotAborted(signal);
|
|
513
|
+
return await withAbort(fs.realpath(current), signal);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
rethrowIfAborted(error);
|
|
517
|
+
const code = isNodeError(error) ? error.code : undefined;
|
|
518
|
+
if (code !== 'ENOENT') {
|
|
519
|
+
throw toMcpError(requestedPath, error);
|
|
520
|
+
}
|
|
521
|
+
const parent = path.dirname(current);
|
|
522
|
+
if (parent === current) {
|
|
523
|
+
throw toMcpError(requestedPath, error);
|
|
524
|
+
}
|
|
525
|
+
current = parent;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function validateExistingPathDetailsInternal(requestedPath, signal) {
|
|
530
|
+
const { allowedDirs, normalizedRequested } = preparePathAccess(requestedPath);
|
|
452
531
|
const realPath = await resolveRealPathOrThrow({
|
|
453
532
|
requestedPath,
|
|
454
533
|
normalizedRequested,
|
|
455
534
|
...(signal ? { signal } : {}),
|
|
456
535
|
});
|
|
457
536
|
const normalizedReal = normalizePath(realPath);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
537
|
+
ensureResolvedPathAllowed({
|
|
538
|
+
requestedPath,
|
|
539
|
+
resolvedPath: realPath,
|
|
540
|
+
normalizedResolved: normalizedReal,
|
|
541
|
+
allowedDirs,
|
|
542
|
+
});
|
|
461
543
|
return {
|
|
462
544
|
requestedPath: normalizedRequested,
|
|
463
545
|
resolvedPath: normalizedReal,
|
|
@@ -473,55 +555,28 @@ export async function validateExistingPath(requestedPath, signal) {
|
|
|
473
555
|
}
|
|
474
556
|
export async function validateExistingDirectory(requestedPath, signal) {
|
|
475
557
|
const details = await validateExistingPathDetailsInternal(requestedPath, signal);
|
|
476
|
-
|
|
477
|
-
try {
|
|
478
|
-
assertNotAborted(signal);
|
|
479
|
-
stats = await withAbort(fs.stat(details.resolvedPath), signal);
|
|
480
|
-
}
|
|
481
|
-
catch (error) {
|
|
482
|
-
rethrowIfAborted(error);
|
|
483
|
-
throw toMcpError(requestedPath, error);
|
|
484
|
-
}
|
|
558
|
+
const stats = await statPathOrThrow(requestedPath, details.resolvedPath, signal);
|
|
485
559
|
if (!stats.isDirectory()) {
|
|
486
560
|
throw new McpError(ErrorCode.E_NOT_DIRECTORY, `Not a directory: ${requestedPath}`, requestedPath);
|
|
487
561
|
}
|
|
488
562
|
return details.resolvedPath;
|
|
489
563
|
}
|
|
490
564
|
export async function validatePathForWrite(requestedPath, signal) {
|
|
491
|
-
const normalizedRequested =
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
565
|
+
const { allowedDirs, normalizedRequested } = preparePathAccess(requestedPath);
|
|
566
|
+
assertAllowedFileAccess(requestedPath, normalizedRequested);
|
|
567
|
+
const realPath = await resolveNearestExistingRealPathOrThrow({
|
|
568
|
+
requestedPath,
|
|
569
|
+
startPath: normalizedRequested,
|
|
570
|
+
...(signal ? { signal } : {}),
|
|
571
|
+
});
|
|
572
|
+
const normalizedReal = normalizePath(realPath);
|
|
573
|
+
ensureResolvedPathAllowed({
|
|
495
574
|
requestedPath,
|
|
575
|
+
resolvedPath: realPath,
|
|
576
|
+
normalizedResolved: normalizedReal,
|
|
496
577
|
allowedDirs,
|
|
497
|
-
details: { normalizedPath: normalizedRequested },
|
|
498
578
|
});
|
|
499
|
-
|
|
500
|
-
let current = normalizedRequested;
|
|
501
|
-
for (;;) {
|
|
502
|
-
try {
|
|
503
|
-
assertNotAborted(signal);
|
|
504
|
-
const realPath = await withAbort(fs.realpath(current), signal);
|
|
505
|
-
const normalizedReal = normalizePath(realPath);
|
|
506
|
-
if (!isPathWithinDirectories(normalizedReal, allowedDirs)) {
|
|
507
|
-
throw toAccessDeniedWithHint(requestedPath, realPath, normalizedReal);
|
|
508
|
-
}
|
|
509
|
-
return normalizedRequested;
|
|
510
|
-
}
|
|
511
|
-
catch (error) {
|
|
512
|
-
rethrowIfAborted(error);
|
|
513
|
-
const code = isNodeError(error) ? error.code : undefined;
|
|
514
|
-
if (code === 'ENOENT') {
|
|
515
|
-
const parent = path.dirname(current);
|
|
516
|
-
if (parent === current) {
|
|
517
|
-
throw toMcpError(requestedPath, error);
|
|
518
|
-
}
|
|
519
|
-
current = parent;
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
throw toMcpError(requestedPath, error);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
579
|
+
return normalizedRequested;
|
|
525
580
|
}
|
|
526
581
|
function isFileRoot(root) {
|
|
527
582
|
return root.uri.startsWith('file://');
|