@j0hanz/filesystem-mcp 1.9.1 → 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 +10 -10
- 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 +2 -0
- package/dist/lib/file-operations/search.js +59 -27
- package/dist/lib/fs-helpers.d.ts +3 -1
- package/dist/lib/fs-helpers.js +63 -0
- package/dist/lib/paths.js +79 -53
- 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 +36 -1
- package/dist/schemas.js +73 -3
- package/dist/server/bootstrap.js +85 -65
- 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;
|
|
@@ -96,6 +101,7 @@ const SearchOptionsSchema = z.strictObject({
|
|
|
96
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.js
CHANGED
|
@@ -16,12 +16,17 @@ export function toPosixPath(value) {
|
|
|
16
16
|
}
|
|
17
17
|
const IS_WINDOWS = platform() === 'win32';
|
|
18
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;
|
|
19
22
|
function normalizePathForMatch(input) {
|
|
20
23
|
return toPosixPath(path.normalize(input));
|
|
21
24
|
}
|
|
22
25
|
function normalizeForMatch(input) {
|
|
23
26
|
const normalized = normalizePathForMatch(input);
|
|
24
|
-
|
|
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();
|
|
25
30
|
}
|
|
26
31
|
function compilePatternGlobs(normalizedPattern) {
|
|
27
32
|
const globs = new Set([normalizedPattern]);
|
|
@@ -156,7 +161,9 @@ function expandHome(filepath) {
|
|
|
156
161
|
// Accept both "~/" and "~\\" for cross-platform UX.
|
|
157
162
|
if (filepath.startsWith('~/') || filepath.startsWith('~\\')) {
|
|
158
163
|
// Avoid `path.join(HOMEDIR, "/foo")` resetting to the filesystem root.
|
|
159
|
-
const rest = filepath
|
|
164
|
+
const rest = filepath
|
|
165
|
+
.slice(HOME_PREFIX_LENGTH)
|
|
166
|
+
.replace(LEADING_SEPARATORS_RE, '');
|
|
160
167
|
return rest.length === 0 ? HOMEDIR : path.join(HOMEDIR, rest);
|
|
161
168
|
}
|
|
162
169
|
return filepath;
|
|
@@ -174,9 +181,12 @@ export function normalizePath(p) {
|
|
|
174
181
|
}
|
|
175
182
|
return resolved;
|
|
176
183
|
}
|
|
177
|
-
function
|
|
184
|
+
function normalizeCaseForComparison(value) {
|
|
178
185
|
return IS_WINDOWS ? value.toLowerCase() : value;
|
|
179
186
|
}
|
|
187
|
+
function normalizeForComparison(value) {
|
|
188
|
+
return normalizeCaseForComparison(value);
|
|
189
|
+
}
|
|
180
190
|
function rethrowIfAborted(error) {
|
|
181
191
|
if (isAbortError(error))
|
|
182
192
|
throw error;
|
|
@@ -184,11 +194,9 @@ function rethrowIfAborted(error) {
|
|
|
184
194
|
function isSamePath(left, right) {
|
|
185
195
|
if (left === right)
|
|
186
196
|
return true;
|
|
187
|
-
const leftResolved = path.resolve(left);
|
|
188
|
-
const rightResolved = path.resolve(right);
|
|
189
|
-
return
|
|
190
|
-
? leftResolved.toLowerCase() === rightResolved.toLowerCase()
|
|
191
|
-
: leftResolved === rightResolved;
|
|
197
|
+
const leftResolved = normalizeCaseForComparison(path.resolve(left));
|
|
198
|
+
const rightResolved = normalizeCaseForComparison(path.resolve(right));
|
|
199
|
+
return leftResolved === rightResolved;
|
|
192
200
|
}
|
|
193
201
|
function stripTrailingSeparator(normalized) {
|
|
194
202
|
return normalized.length > 1 && normalized.endsWith(PATH_SEPARATOR)
|
|
@@ -335,7 +343,7 @@ function getReservedDeviceName(segment) {
|
|
|
335
343
|
let end = segment.length;
|
|
336
344
|
while (end > 0) {
|
|
337
345
|
const c = segment.charCodeAt(end - 1);
|
|
338
|
-
if (c ===
|
|
346
|
+
if (c === CHAR_CODE_SPACE || c === CHAR_CODE_DOT)
|
|
339
347
|
end--; // space or dot
|
|
340
348
|
else
|
|
341
349
|
break;
|
|
@@ -469,7 +477,7 @@ async function resolveRealPathOrThrow(options) {
|
|
|
469
477
|
throw toMcpError(requestedPath, error);
|
|
470
478
|
}
|
|
471
479
|
}
|
|
472
|
-
|
|
480
|
+
function preparePathAccess(requestedPath) {
|
|
473
481
|
const normalizedRequested = validateRequestedPath(requestedPath);
|
|
474
482
|
const allowedDirs = getAllowedDirectories();
|
|
475
483
|
ensureWithinAllowedDirectories({
|
|
@@ -478,15 +486,60 @@ async function validateExistingPathDetailsInternal(requestedPath, signal) {
|
|
|
478
486
|
allowedDirs,
|
|
479
487
|
details: { normalizedPath: normalizedRequested },
|
|
480
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);
|
|
481
531
|
const realPath = await resolveRealPathOrThrow({
|
|
482
532
|
requestedPath,
|
|
483
533
|
normalizedRequested,
|
|
484
534
|
...(signal ? { signal } : {}),
|
|
485
535
|
});
|
|
486
536
|
const normalizedReal = normalizePath(realPath);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
537
|
+
ensureResolvedPathAllowed({
|
|
538
|
+
requestedPath,
|
|
539
|
+
resolvedPath: realPath,
|
|
540
|
+
normalizedResolved: normalizedReal,
|
|
541
|
+
allowedDirs,
|
|
542
|
+
});
|
|
490
543
|
return {
|
|
491
544
|
requestedPath: normalizedRequested,
|
|
492
545
|
resolvedPath: normalizedReal,
|
|
@@ -502,55 +555,28 @@ export async function validateExistingPath(requestedPath, signal) {
|
|
|
502
555
|
}
|
|
503
556
|
export async function validateExistingDirectory(requestedPath, signal) {
|
|
504
557
|
const details = await validateExistingPathDetailsInternal(requestedPath, signal);
|
|
505
|
-
|
|
506
|
-
try {
|
|
507
|
-
assertNotAborted(signal);
|
|
508
|
-
stats = await withAbort(fs.stat(details.resolvedPath), signal);
|
|
509
|
-
}
|
|
510
|
-
catch (error) {
|
|
511
|
-
rethrowIfAborted(error);
|
|
512
|
-
throw toMcpError(requestedPath, error);
|
|
513
|
-
}
|
|
558
|
+
const stats = await statPathOrThrow(requestedPath, details.resolvedPath, signal);
|
|
514
559
|
if (!stats.isDirectory()) {
|
|
515
560
|
throw new McpError(ErrorCode.E_NOT_DIRECTORY, `Not a directory: ${requestedPath}`, requestedPath);
|
|
516
561
|
}
|
|
517
562
|
return details.resolvedPath;
|
|
518
563
|
}
|
|
519
564
|
export async function validatePathForWrite(requestedPath, signal) {
|
|
520
|
-
const normalizedRequested =
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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({
|
|
524
574
|
requestedPath,
|
|
575
|
+
resolvedPath: realPath,
|
|
576
|
+
normalizedResolved: normalizedReal,
|
|
525
577
|
allowedDirs,
|
|
526
|
-
details: { normalizedPath: normalizedRequested },
|
|
527
578
|
});
|
|
528
|
-
|
|
529
|
-
let current = normalizedRequested;
|
|
530
|
-
for (;;) {
|
|
531
|
-
try {
|
|
532
|
-
assertNotAborted(signal);
|
|
533
|
-
const realPath = await withAbort(fs.realpath(current), signal);
|
|
534
|
-
const normalizedReal = normalizePath(realPath);
|
|
535
|
-
if (!isPathWithinDirectories(normalizedReal, allowedDirs)) {
|
|
536
|
-
throw toAccessDeniedWithHint(requestedPath, realPath, normalizedReal);
|
|
537
|
-
}
|
|
538
|
-
return normalizedRequested;
|
|
539
|
-
}
|
|
540
|
-
catch (error) {
|
|
541
|
-
rethrowIfAborted(error);
|
|
542
|
-
const code = isNodeError(error) ? error.code : undefined;
|
|
543
|
-
if (code === 'ENOENT') {
|
|
544
|
-
const parent = path.dirname(current);
|
|
545
|
-
if (parent === current) {
|
|
546
|
-
throw toMcpError(requestedPath, error);
|
|
547
|
-
}
|
|
548
|
-
current = parent;
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
throw toMcpError(requestedPath, error);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
579
|
+
return normalizedRequested;
|
|
554
580
|
}
|
|
555
581
|
function isFileRoot(root) {
|
|
556
582
|
return root.uri.startsWith('file://');
|
|
@@ -6,6 +6,7 @@ export interface TextResourceEntry {
|
|
|
6
6
|
hash: string;
|
|
7
7
|
size: number;
|
|
8
8
|
storedAt: string;
|
|
9
|
+
expiresAt: string;
|
|
9
10
|
}
|
|
10
11
|
export interface ResourceStore {
|
|
11
12
|
putText(params: {
|
|
@@ -21,6 +22,7 @@ interface ResourceStoreOptions {
|
|
|
21
22
|
maxEntries: number;
|
|
22
23
|
maxTotalBytes: number;
|
|
23
24
|
maxEntryBytes: number;
|
|
25
|
+
entryTtlMs: number;
|
|
24
26
|
}
|
|
25
27
|
export declare function createInMemoryResourceStore(options?: Partial<ResourceStoreOptions>): ResourceStore;
|
|
26
28
|
export {};
|