@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.
Files changed (45) hide show
  1. package/README.md +10 -10
  2. package/dist/completions.js +140 -115
  3. package/dist/lib/constants.d.ts +1 -0
  4. package/dist/lib/constants.js +2 -0
  5. package/dist/lib/file-operations/metadata.d.ts +5 -1
  6. package/dist/lib/file-operations/metadata.js +14 -1
  7. package/dist/lib/file-operations/search.d.ts +2 -0
  8. package/dist/lib/file-operations/search.js +59 -27
  9. package/dist/lib/fs-helpers.d.ts +3 -1
  10. package/dist/lib/fs-helpers.js +63 -0
  11. package/dist/lib/paths.js +79 -53
  12. package/dist/lib/resource-store.d.ts +2 -0
  13. package/dist/lib/resource-store.js +58 -17
  14. package/dist/prompts.d.ts +2 -0
  15. package/dist/prompts.js +51 -0
  16. package/dist/resources/generated-instructions.js +36 -9
  17. package/dist/resources/tool-catalog.js +30 -7
  18. package/dist/resources/tool-info.d.ts +4 -0
  19. package/dist/resources/tool-info.js +21 -3
  20. package/dist/resources/workflows.js +17 -5
  21. package/dist/schemas.d.ts +36 -1
  22. package/dist/schemas.js +73 -3
  23. package/dist/server/bootstrap.js +85 -65
  24. package/dist/tools/apply-patch.js +135 -31
  25. package/dist/tools/calculate-hash.js +13 -8
  26. package/dist/tools/create-directory.js +14 -3
  27. package/dist/tools/delete-file.js +1 -0
  28. package/dist/tools/diff-files.js +26 -8
  29. package/dist/tools/edit-file.js +11 -8
  30. package/dist/tools/list-directory.js +1 -6
  31. package/dist/tools/move-file.js +39 -7
  32. package/dist/tools/read-multiple.js +9 -1
  33. package/dist/tools/read.js +38 -6
  34. package/dist/tools/replace-in-files.js +72 -25
  35. package/dist/tools/roots.js +1 -0
  36. package/dist/tools/search-content.js +76 -48
  37. package/dist/tools/search-files.js +6 -7
  38. package/dist/tools/shared.d.ts +2 -1
  39. package/dist/tools/shared.js +36 -20
  40. package/dist/tools/stat-many.js +1 -1
  41. package/dist/tools/stat.js +4 -0
  42. package/dist/tools/task-support.js +4 -12
  43. package/dist/tools/tree.js +4 -0
  44. package/dist/tools/write-file.js +4 -2
  45. 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
- const regex = new RE2(final, caseSensitive ? 'g' : 'gi');
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
- // Helper to manage pool filling
520
- async function fillWorkerPool(pool, pending, iterator, pattern, matcherOpts, scanOpts, maxResults, currentMatches, summary) {
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(pool, pending, iterator, pattern, matcherOpts, scanOpts, opts.maxResults, matches.length, summary);
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
- // Update summary truncation
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, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress) {
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, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress);
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
- return `${pattern}|${cs}|${ww}|${lit}`;
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);
@@ -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;
@@ -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
- return IS_WINDOWS ? normalized.toLowerCase() : normalized;
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.slice(2).replace(LEADING_SEPARATORS_RE, '');
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 normalizeForComparison(value) {
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 IS_WINDOWS
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 === 32 || c === 46)
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
- async function validateExistingPathDetailsInternal(requestedPath, signal) {
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
- if (!isPathWithinDirectories(normalizedReal, allowedDirs)) {
488
- throw toAccessDeniedWithHint(requestedPath, realPath, normalizedReal);
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
- let stats;
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 = validateRequestedPath(requestedPath);
521
- const allowedDirs = getAllowedDirectories();
522
- ensureWithinAllowedDirectories({
523
- normalizedPath: normalizedRequested,
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
- assertAllowedFileAccess(requestedPath, normalizedRequested);
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 {};