Skip to content

fix(filesystem): name a broken symlink instead of blaming its parent - #4915

Open
v0ropaev wants to merge 1 commit into
modelcontextprotocol:v2/mainfrom
v0ropaev:fix/broken-symlink-message
Open

v0ropaev wants to merge 1 commit into
modelcontextprotocol:v2/mainfrom
v0ropaev:fix/broken-symlink-message

Conversation

@v0ropaev

Copy link
Copy Markdown

Description

A symlink pointing at nothing makes the filesystem server report a missing parent directory for a directory that is plainly there.

Server Details

  • Server: filesystem
  • Changes to: path validation (src/filesystem/lib.ts), one new test file

Motivation and Context

validatePath catches ENOENT out of resolveUnicodeEquivalentPath and turns it into

Parent directory does not exist: <dirname>

That message never fits the case it names. When a path component is genuinely missing, the resolver does not throw: it returns the joined tail, with a comment saying so, precisely so create_directory can mkdir -p it. What does raise ENOENT in there is fs.realpath on an entry that appears in the directory listing but points nowhere.

So with an allowed directory R holding a dangling link.txt:

Parent directory does not exist: /var/folders/.../mcp-broken-symlink-N9OgbP

R exists, fs.stat(R) succeeds, and the caller is sent looking at the wrong thing. A stale node_modules/.bin entry gives the same shape.

The fix classifies the error where it happens, at the realpath call, rather than guessing from an errno two frames up:

Broken symlink: <path>

I left the existing fallback in place. The other ENOENT sources in that function are a vanished allowed directory and an intermediate directory removed mid-walk, where "parent directory does not exist" reads closer to true.

I first tried putting an fs.stat(parent) check in the catch to tell the two apart. That is worse: __tests__/lib.test.ts auto-mocks fs/promises, so fs.stat resolves to undefined and the check passes for both cases. Classifying at the source needs no such guess.

How Has This Been Tested?

New src/filesystem/__tests__/broken-symlink.test.ts, two cases against a real temp directory: a dangling symlink now reports Broken symlink while its parent still stats fine, and a genuinely missing path still resolves to the joined tail so create_directory keeps working. The first fails on current main with the message quoted above.

npx vitest run     167 passed, 3 failed
npx tsc --noEmit   clean

The three failures are in __tests__/unicode-paths.test.ts and are the same three on unmodified main in this checkout; they look like the composed/decomposed distinction not surviving on APFS. Not touched by this change.

Not tested through an LLM client, this is an error-message path.

Breaking Changes

None. No client configuration changes, and the only difference a caller sees is the wording of an error that was already being raised.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • I have read the MCP Protocol Documentation
  • My changes follows MCP security best practices
  • I have updated the server's README accordingly (no user-facing behaviour to document, only the error text)
  • I have tested this with an LLM client
  • 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 (none added)

Copilot AI balanced review requested due to automatic review settings September 30, 2026 09:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI balanced review requested due to automatic review settings October 12, 2026 09:34
@changeset-bot

changeset-bot Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9b7b641

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/server-filesystem Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI balanced review requested due to automatic review settings October 12, 2026 09:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@v0ropaev

Copy link
Copy Markdown
Author

Rebuilt on current main. Two things moved under it since September, so the diff is not the same one any more.

lib.ts got reformatted by prettier, and validatePath now takes the allow list as a parameter instead of reading module state (#4854), so the fix and its test are reapplied on the new shapes. The new preserve-caught-error lint rule wants the realpath error attached, so the thrown error carries { cause: error }, the same way the Parent directory does not exist error ten lines below it already does.

The part worth your judgement is a test. 3a63f52 added symlinks > refuses to write through a dangling symlink, reporting a missing parent, which asserts exactly the message this PR changes. I read it as a characterization snapshot rather than a decision: the commit message for that suite says the tests "assert what the server does today so the SDK v2 migration can prove it changed nothing", and the follow-up 802c225 that tagged the wrong ones with KNOWN BUG put only one marker in that file, for #1970. So I changed its name and its message assertion. The security assertion inside it, that nothing gets created outside the allowed directory, is untouched and still passes. If you meant that message to stay, say so and I will close this instead of arguing with a test you wrote on purpose.

I also added a guard that a symlink cycle still reports ELOOP. The catch I am adding looks only at ENOENT, and that test fails if someone later widens it to rename every realpath failure into a broken symlink.

npm run validate -w src/filesystem and npm run validate:guards are both clean here, 342 tests pass, coverage for lib.ts is 99.58 percent. Changeset added as a patch, since lib.ts ships.

Verified again that the test is red without the fix: on main it reports Parent directory does not exist: /var/folders/..., naming a directory that is right there, for a symlink that simply has nothing at the other end.

Copilot AI balanced review requested due to automatic review settings October 12, 2026 09:52
@v0ropaev
v0ropaev changed the base branch from main to v2/main October 12, 2026 09:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@v0ropaev

Copy link
Copy Markdown
Author

Retargeted onto v2/main and rebuilt there. I had it pointed at main, which the Source is v2/main check rightly rejected: CONTRIBUTING says development happens on v2/main and main only takes the milestone merge. The diff is the same four files, v2/main merged in cleanly with no conflicts.

On v2/main: npm run validate -w src/filesystem passes with 348 tests, npm run validate:guards passes 712, coverage for lib.ts is 99.58 percent.

validatePath on a symlink with nothing at the other end reported
"Parent directory does not exist: <dir>" for a directory that is plainly
there. resolveUnicodeEquivalentPath lets the ENOENT from fs.realpath escape
bare, and validatePath's catch reads any ENOENT as a missing parent.

Naming the symlink needs the catch inside the equivalent-path walk, where the
failing entry is known. Only ENOENT is renamed; the realpath error is attached
as the cause, the way the Parent directory error below it already does.

The characterization test for this case asserted the old message. Its name and
assertion now follow the fix; the security assertion in it, that nothing is
created outside the allowed directory, is unchanged. Added a guard that a
symlink cycle still reports ELOOP, so a later, broader catch cannot rename
every realpath failure into a broken symlink.

Signed-off-by: Dmitry Voropaev <dy.voropaev@gmail.com>
@v0ropaev
v0ropaev force-pushed the fix/broken-symlink-message branch from f35a018 to 9b7b641 Compare October 12, 2026 10:21

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants