Skip to content

feat(filesystem): add --follow-symlinks and --symlink-depth options#3678

Open
CodeForgeNet wants to merge 3 commits intomodelcontextprotocol:mainfrom
CodeForgeNet:feat/filesystem-follow-symlinks
Open

feat(filesystem): add --follow-symlinks and --symlink-depth options#3678
CodeForgeNet wants to merge 3 commits intomodelcontextprotocol:mainfrom
CodeForgeNet:feat/filesystem-follow-symlinks

Conversation

@CodeForgeNet
Copy link
Copy Markdown

Closes #3457

The filesystem server currently blocks any symlink whose resolved target
falls outside the allowed directories -- no exceptions. This works well
as a default but is too rigid for common Unix workflows where symlinks
point to external storage, shared mounts, or locations managed outside
the server's root.

This PR adds two optional CLI flags: --follow-symlinks to opt in to
cross-boundary symlink resolution, and --symlink-depth=N to cap how
many hops outside allowed directories are permitted (default: 1).

The default behavior is unchanged. If you're not using these flags,
nothing about path validation is different.

Server Details

  • Server: filesystem
  • Changes to: path validation logic, CLI argument parsing, documentation

Motivation and Context

Users with symlinks into mounted drives, shared NFS paths, or external
directories kept having to add those paths to their allowed list manually.
The request was for an opt-in way to follow symlinks outside the boundary
without giving up all security enforcement.

The depth limit keeps it bounded: you choose how many hops outside the
allowed directories are acceptable before the server blocks access.

How Has This Been Tested?

13 new test cases in __tests__/symlinks.test.ts covering:

  • Symlink within allowed dir (existing behavior, unaffected)
  • Symlink outside allowed dir with flag off (existing hard block, unaffected)
  • Symlink outside allowed dir with flag on, within depth limit
  • Symlink chain exceeding max depth
  • Symlink that routes back into an allowed dir after an outside hop
  • Circular symlink detection

All existing tests (53 in path-validation.test.ts) still pass.

Breaking Changes

None. The flags are opt-in. Existing configs work exactly as before.

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the MCP Protocol Documentation
  • My changes follows MCP security best practices
  • I have updated the server's README accordingly
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have documented all environment variables and configuration options

Additional context

Circular symlinks are detected via a visited Set and throw a clear error
rather than looping. The depth counter only increments for hops that land
outside allowed directories -- hops within allowed dirs don't count against
the limit.

Closes modelcontextprotocol#3457

- Add followSymlinks and symlinkMaxDepth policy to lib.ts
- Modify validatePath() to use lstat + hop-by-hop resolution when enabled
- Circular symlink detection via visited Set
- Filter -- flags from dirArgs before building allowedDirectories
- 13 new test cases covering all symlink scenarios
- Default behavior unchanged (followSymlinks: false)
@CodeForgeNet
Copy link
Copy Markdown
Author

This implements the feature requested in #3457. Happy to make any changes based on feedback.

@cliffhall cliffhall added enhancement New feature or request server-filesystem Reference implementation for the Filesystem MCP server - src/filesystem labels Apr 20, 2026
@cliffhall
Copy link
Copy Markdown
Member

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 21, 2026

Claude finished @cliffhall's task in 2m 52s —— View job


PR Review: feat(filesystem): add --follow-symlinks and --symlink-depth options

  • Gather context and understand the request
  • Read changed files (lib.ts, index.ts, symlinks.test.ts, README.md)
  • Analyze security, correctness, and edge cases
  • Post review feedback

Overall the implementation is well-structured and the circular symlink detection is correct. The default behavior is genuinely unchanged. I have a few findings ranging from a write-access security implication that needs clearer documentation, to a correctness issue in the depth-counting logic, to a handful of code-quality items.


Security: Write access through symlinks is not called out in the README

validatePath is used by both read and write tools (write_file, edit_file, move_file). When --follow-symlinks is on, validatePath can return a path like /etc/passwd as the "valid" target, and the caller will then write to it.

The README only says: "Allow symlinks to point to targets outside allowed directories" — nothing about writes. A user enabling this flag for NFS read access probably doesn't realize they've also enabled writes to arbitrary outside paths via a planted symlink.

Recommendation: Add a prominent note in README.md under the --follow-symlinks flag documentation:

⚠️ This flag applies to all operations including writes. A symlink inside an allowed directory can point to any file outside (up to --symlink-depth hops), and write tools will follow it. Only enable this flag if you trust the contents of your allowed directories.

Fix this →


Correctness: Double fs.realpath call on the non-symlink code path

lib.ts lines 199–220: when the path is not a symlink, there are two separate fs.realpath(absolute) calls with the same input:

// First call
let realPath: string;
try {
  realPath = await fs.realpath(absolute);
} ...

// Second call — same argument, always same result
let resolvedAbsolute = absolute;
try {
  resolvedAbsolute = await fs.realpath(absolute);
} ...

// Then both normalizedReal and normalizedResolved are checked,
// but they will always be equal

resolvedAbsolute is redundant — it will always equal realPath. The && condition on line 222 therefore reduces to !isPathWithinAllowedDirectories(normalizedReal, ...) alone. The second branch is dead code that also adds an extra syscall.

Fix this →


Correctness: Incorrect hop count in test comment

symlinks.test.ts line 198:

// Chain has 2 hops outside allowed dirs, exceeds maxDepth of 1

The chain is finalLink → link2 → link1 → targetFile. All three destinations (link2, link1, targetFile) land in forbiddenDir, so resolveSymlinkHopByHop increments hopsOutsideAllowed three times, returning [targetFile, 3]. The test still passes because 3 > 1, but the comment (and the companion test at line 209 that says "Chain has 2 hops outside allowed dirs, within maxDepth of 3") is wrong. maxDepth: 3 allows exactly 3 outside hops, which is exactly what this chain produces — so the "within" test is actually at the edge, not comfortably within.

Fix this →


Code quality: stats?.isSymbolicLink() optional chaining is covering for test mocks

lib.ts line 195:

const stats = await fs.lstat(absolute);
if (!stats?.isSymbolicLink()) {

fs.lstat either resolves with a valid Stats object or throws — it never returns null or undefined. The comment says: "Use optional chaining to handle test mocks where stats might be undefined". This is a test-driven workaround that muddies production code. Better to fix the mock, or if the mock genuinely needs to return undefined, use a proper type assertion or add an explicit null check with a throw. Optional chaining here silently treats undefined stats as "not a symlink", which could mask bugs.


Code quality: Test skipping pattern hides skipped tests

The symlink tests use this pattern:

if (!symlinkSupported) {
  console.log('   ⏭️  Skipping - symlinks not supported');
  return;
}

Vitest marks these tests as passed, not skipped. CI will look green even though most of the test suite didn't run. Use vitest's built-in mechanism instead:

let symlinkSupported: boolean;
beforeAll(async () => { symlinkSupported = await getSymlinkSupport(); });

it('test name', async function() {
  if (!symlinkSupported) this.skip();
  // ...
});

Or restructure using describe.skipIf.

Fix this →


Minor: --symlink-depth silently has no effect without --follow-symlinks

index.ts lines 110–117 parse and store --symlink-depth=N unconditionally, but if --follow-symlinks is not also passed, the value is stored in lib.ts and never consulted. A user who passes --symlink-depth=3 thinking it affects something will get no feedback. A one-liner warning at startup would help:

if (symlinkMaxDepth !== 1 && !followSymlinks) {
  console.error("Warning: --symlink-depth has no effect without --follow-symlinks");
}

Summary

Severity Finding
🔴 Should address Document write-access implications of --follow-symlinks in README
🟡 Should fix Remove redundant double fs.realpath call in non-symlink path
🟡 Should fix Correct test hop-count comments (says 2 hops, actually 3)
🟡 Should fix Replace test early-returns with vitest skip so CI shows accurate skip counts
🟠 Consider Remove optional chaining on stats?.isSymbolicLink() — fix the mock instead
🔵 Nice to have Warn at startup if --symlink-depth is set without --follow-symlinks

The core logic (hop-by-hop resolution, depth enforcement, circular detection) is correct. The security model is sound given the flag is opt-in. The write-access documentation gap is the most important thing to address before merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request server-filesystem Reference implementation for the Filesystem MCP server - src/filesystem

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Filesystem MCP follow symlinks option

2 participants