@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.
Files changed (48) hide show
  1. package/README.md +15 -15
  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 +7 -5
  8. package/dist/lib/file-operations/search.js +64 -32
  9. package/dist/lib/fs-helpers.d.ts +3 -1
  10. package/dist/lib/fs-helpers.js +63 -0
  11. package/dist/lib/paths.d.ts +8 -0
  12. package/dist/lib/paths.js +119 -64
  13. package/dist/lib/resource-store.d.ts +2 -0
  14. package/dist/lib/resource-store.js +58 -17
  15. package/dist/prompts.d.ts +2 -0
  16. package/dist/prompts.js +51 -0
  17. package/dist/resources/generated-instructions.js +36 -9
  18. package/dist/resources/tool-catalog.js +30 -7
  19. package/dist/resources/tool-info.d.ts +4 -0
  20. package/dist/resources/tool-info.js +21 -3
  21. package/dist/resources/workflows.js +17 -5
  22. package/dist/schemas.d.ts +47 -12
  23. package/dist/schemas.js +75 -18
  24. package/dist/server/bootstrap.js +103 -91
  25. package/dist/server/roots-manager.d.ts +3 -0
  26. package/dist/server/roots-manager.js +15 -3
  27. package/dist/tools/apply-patch.js +135 -31
  28. package/dist/tools/calculate-hash.js +13 -8
  29. package/dist/tools/create-directory.js +14 -3
  30. package/dist/tools/delete-file.js +1 -0
  31. package/dist/tools/diff-files.js +26 -8
  32. package/dist/tools/edit-file.js +11 -8
  33. package/dist/tools/list-directory.js +1 -6
  34. package/dist/tools/move-file.js +39 -7
  35. package/dist/tools/read-multiple.js +9 -1
  36. package/dist/tools/read.js +38 -6
  37. package/dist/tools/replace-in-files.js +72 -25
  38. package/dist/tools/roots.js +1 -0
  39. package/dist/tools/search-content.js +76 -48
  40. package/dist/tools/search-files.js +6 -7
  41. package/dist/tools/shared.d.ts +2 -1
  42. package/dist/tools/shared.js +36 -20
  43. package/dist/tools/stat-many.js +1 -1
  44. package/dist/tools/stat.js +4 -0
  45. package/dist/tools/task-support.js +4 -12
  46. package/dist/tools/tree.js +4 -0
  47. package/dist/tools/write-file.js +4 -2
  48. 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;
@@ -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.number().int().nonnegative(),
92
- maxFileSize: z.number().int().nonnegative(),
93
- maxFilesScanned: z.number().int().nonnegative(),
94
- timeoutMs: z.number().int().nonnegative(),
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.number().int().nonnegative(),
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
  }
@@ -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
- 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();
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.slice(2).replace(LEADING_SEPARATORS_RE, '');
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 normalizeForComparison(value) {
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 IS_WINDOWS
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 allowedDirectoriesExpanded = [];
222
- let allowedDirectoriesPrimary = [];
239
+ let defaultAllowedDirectoriesState = {
240
+ primary: [],
241
+ expanded: [],
242
+ };
223
243
  function setAllowedDirectoriesState(primary, expanded) {
224
- allowedDirectoriesPrimary = dedupePreserveOrder(primary);
225
- allowedDirectoriesExpanded = dedupePreserveOrder(expanded);
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 [...allowedDirectoriesExpanded];
262
+ return [...getActiveAllowedDirectoriesState().expanded];
229
263
  }
230
264
  export function isAllowedDirectoryRoot(normalizedPath) {
231
- for (const dir of allowedDirectoriesExpanded) {
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
- return allowedDirectoriesPrimary.length > 0
239
- ? allowedDirectoriesPrimary
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 setAllowedDirectoriesResolved(dirs, signal) {
322
+ export async function resolveAllowedDirectoriesState(dirs, signal) {
290
323
  const primary = normalizeAllowedDirectories(dirs);
291
324
  const expanded = await expandAllowedDirectories(primary, signal);
292
- setAllowedDirectoriesState(primary, expanded);
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 === 32 || c === 46)
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
- async function validateExistingPathDetailsInternal(requestedPath, signal) {
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
- if (!isPathWithinDirectories(normalizedReal, allowedDirs)) {
459
- throw toAccessDeniedWithHint(requestedPath, realPath, normalizedReal);
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
- let stats;
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 = validateRequestedPath(requestedPath);
492
- const allowedDirs = getAllowedDirectories();
493
- ensureWithinAllowedDirectories({
494
- 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({
495
574
  requestedPath,
575
+ resolvedPath: realPath,
576
+ normalizedResolved: normalizedReal,
496
577
  allowedDirs,
497
- details: { normalizedPath: normalizedRequested },
498
578
  });
499
- assertAllowedFileAccess(requestedPath, normalizedRequested);
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://');