Skip to content

fix(filesystem): allow a client Root in its advertised form as well as its realpath - #5317

Merged
cliffhall merged 3 commits into
v2/mainfrom
v2/fix/5183-filesystem-root-advertised-form
Oct 27, 2026
Merged

cliffhall merged 3 commits into
v2/mainfrom
v2/fix/5183-filesystem-root-advertised-form

Conversation

@cliffhall

@cliffhall cliffhall commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Closes #5183

Description

A client Root is now allowed in the form the client advertised it as well as in its resolved (realpath) form, deduplicated, which is how resolveAllowedDirectories (index.ts) already treats command-line directories.

  • parseRootUri (roots-utils.ts) returns both the absolute path as advertised (formatted with normalizePathFormat, never trimmed, since trimming would name a different directory) and the normalized realpath of a Root, instead of the realpath alone. The pre-existing trimming of the realpath itself is filesystem: allowed directories are trimmed after realpath, so a name ending in a space or quote allows its sibling #5320.

  • getValidRootDirectories stats the resolved path once, as before, and admits both forms of each Root that is a directory, deduplicated across Roots (a Root advertised in both spellings is listed once per spelling).

  • resolveUnicodeEquivalentPath (lib.ts, the missing-path branch of validatePath) now checks the allowed directory's own realpath against the allowed directories before appending a missing tail. An advertised form can be a symlink retargeted after the Roots load; without this, write_file/create_directory under it landed outside (Copilot round 2). This also covers a symlinked command-line directory.

Access stays bounded by the resolved directories: validatePath still realpaths every request and re-checks it against the allowed list, so a symlink inside a Root that points outside it is refused under either spelling (pinned by a new test).

The two-forms logic is written in roots-utils.ts rather than extracted into a helper shared with index.ts: #5185 is changing resolveAllowedDirectories' realpath handling in parallel, and a shared helper would put both PRs in the same hunk. The two now behave the same; folding them into one helper can follow once #5185 lands.

Server Details

  • Server: filesystem
  • Changes to: client Roots handling (roots-utils.ts), the missing-path check in lib.ts, so list_allowed_directories and every path-taking tool on a 2025-era connection with Roots. README (Roots section, list_allowed_directories).

Motivation and Context

On macOS /tmp is a symlink to /private/tmp. A Root of file:///tmp/project was allowed only as /private/tmp/project, so a request for /tmp/project/x (the Root's own spelling) was refused with "Access denied", although the same directory given on the command line works under either spelling. The same applies to any symlinked project directory, and per the issue to a Windows mapped drive that resolves to a UNC path.

Shared functions: this changes parseRootUri and getValidRootDirectories, which #5185 also touches (parseRootUri's realpath/retry behavior), and adds one block to lib.ts resolveUnicodeEquivalentPath, on validatePath's ENOENT path, which #5185 and #4162 also work in. It does not touch the roots update flow in server.ts (#3602) or index.ts.

Prior work: #4145 by @nnish16 and #4275 by @yyzquwu, closed in the backlog triage (#4875), were the prototypes for this fix. Thank you to both.

How Has This Been Tested?

Tests (protocol level, __tests__/server-roots.test.ts), written first and failing on v2/main:

  • a symlinked Root is listed as [link, target], and read_text_file succeeds through both spellings;
  • a symlink inside a symlinked Root that points outside it is refused through both spellings;
  • on macOS, a Root under /tmp/... is listed as /tmp/... and /private/tmp/..., and a read through /tmp/... succeeds (it.runIf(darwin));
  • a Root advertised in both spellings is listed once per spelling;
  • a Root advertised as alias (trailing space, a symlink) does not allow an unrelated sibling alias: the advertised form is kept verbatim (normalizePathFormat), not trimmed (Copilot round 1; POSIX only);
  • a Root alias retargeted outside the Root after the Roots load: write_file and create_directory under it are refused, and nothing is created outside (Copilot round 2).

The existing "resolve a symlinked root to its target" test asserted the old single-form behavior and is replaced by the first of these.

Inspector CLI 2.12.0, with Roots supplied through the config file's per-server roots setting. Server started with command-line directory /tmp/mcp5183/cli and Root file:///tmp/mcp5183/root (/tmp → /private/tmp); root/note.txt is a file, root/escape.txt a symlink to cli/secret.txt. Before = v2/main's roots-utils, after = this branch.

2025-11-25 (--protocol-era legacy), where Roots apply:

Request Before After
list_allowed_directories /private/tmp/mcp5183/root /tmp/mcp5183/root, /private/tmp/mcp5183/root
read_text_file /tmp/mcp5183/root/note.txt Access denied - path outside allowed directories: /tmp/mcp5183/root/note.txt not in /private/tmp/mcp5183/root inside the root
read_text_file /private/tmp/mcp5183/root/note.txt inside the root inside the root
read_text_file /tmp/mcp5183/root/escape.txt Access denied - path outside allowed directories: … Access denied - symlink target outside allowed directories: /private/tmp/mcp5183/cli/secret.txt not in /tmp/mcp5183/root, /private/tmp/mcp5183/root

2026-07-28 (--protocol-era modern, _meta carries io.modelcontextprotocol/serverInfo): Roots are never requested on this era, so before and after are identical: list_allowed_directories returns the command-line directory in both forms (/tmp/mcp5183/cli, /private/tmp/mcp5183/cli), and every read under the Root is refused as outside it.

LLM client: Claude Code 2.1.311 (claude -p, Sonnet, local build via --strict-mcp-config), prompt "call list_allowed_directories and then read_text_file on /tmp/mcp5183/root/note.txt" with /tmp/mcp5183/root on the command line → Allowed directories: /tmp/mcp5183/root /private/tmp/mcp5183/root, inside the root. Claude Code negotiates 2026-07-28 and has no era switch, so it never sends Roots: this run shows the command-line path unchanged, and the Roots change itself was exercised only through the Inspector CLI on 2025-11-25 and the tests.

Windows: the mapped-drive case in the issue is not verified on a real Windows machine; it is covered only by the windows-latest CI leg running the same suites.

npm run local:gate: exit 0.

Breaking Changes

None. A Root's resolved form is still allowed; its advertised form is now allowed too. list_allowed_directories lists a symlinked Root twice, once per form, as it already did for a symlinked command-line directory.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Protocol Documentation (Roots, 2025-11-25)
  • My changes follow MCP security best practices (every request is still realpath'd and checked against the resolved directories; pinned by the escape test)
  • I have updated the server's README accordingly
  • I have added a changeset (npm run changeset) if this changes what a TypeScript server publishes (patch)
  • I have tested this with an LLM client (Claude Code; it negotiates 2026-07-28 and so cannot send Roots, see above)
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling (unchanged: an unresolvable or non-directory Root is still skipped and logged)
  • I have documented all environment variables and configuration options (not applicable: none added)

🤖 Generated with Claude Code

@cliffhall cliffhall added the v2 label Oct 27, 2026
@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7471ab

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.

🟡 Changes recommended

Advertised-path normalization can strip legal filename characters and authorize an unrelated directory outside the resolved Root.

1 open issue
What changed in this PR

This PR addresses #5183 by allowing filesystem client Roots in both their advertised and resolved forms, matching command-line directory handling.

Changes:

  • Preserve and deduplicate both Root path forms.
  • Add protocol-level tests for alias access, symlink escapes, macOS /tmp, and deduplication.
  • Document the behavior and add a patch changeset.
File Description
src/​filesystem/​roots-utils.ts Retains both path forms for valid Roots.
src/​filesystem/​README.md Documents alias access and directory listings.
src/​filesystem/​__tests__/​server-roots.test.ts Covers dual-form access, escape rejection, and deduplication.
.changeset/​5183-filesystem-root-advertised-form.md Records the filesystem patch release change.

Review effort: Balanced

Comment thread src/filesystem/roots-utils.ts Outdated
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 1: one finding, fixed.

Also rebased onto v2/main after #5315, #5316 and #5319 merged; npm run local:gate exit 0 on the rebased head. Requesting round 2.

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.

🟡 Changes recommended

A retargeted Root alias can bypass containment checks for missing paths and permit creation outside the resolved Root.

1 open issue
1 resolved since last review

Review effort: Balanced

const stats: Stats = await fs.stat(resolvedPath);
if (stats.isDirectory()) {
validatedDirectories.push(resolvedPath);
validatedDirectories.add(advertised);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 60f3cd4. Confirmed first with a failing protocol-level test: after the Roots load, the Root alias is retargeted to a directory outside it, and write_file under the alias succeeded there. resolveUnicodeEquivalentPath (lib.ts) now checks the allowed directory's own realpath against the allowed directories before walking the missing tail, as it already did for every component below it, so write_file and create_directory under a retargeted alias are refused and nothing is created outside. The test covers both tools.

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 2: one finding, fixed.

  • Creation under a retargeted Root alias (lib.ts resolveUnicodeEquivalentPath): reproduced with a failing test (alias retargeted outside after the Roots load; write_file under it succeeded). Fixed in 60f3cd4: the allowed directory's starting realpath is now checked against the allowed directories like every component below it. Regression covers write_file and create_directory, and checks nothing landed outside.

This touches validatePath's ENOENT path, which the ledger lists for #5185 (and #4162): one added block at the top of resolveUnicodeEquivalentPath. It also closes the same gap for a symlinked command-line directory, which already had both forms allowed.

npm run local:gate exit 0. Requesting round 3.

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.

🔵 Needs a closer look

The security-sensitive change to directory authorization warrants human confirmation that advertised aliases preserve the intended resolved-directory boundary.

1 open issue

Review effort: Balanced

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 3 (on 60f3cd4): no new comments. The review's one listed open item is the round-2 thread ("Prevent aliased file creation outside configured roots"), which is still unresolved only because resolving is the reviewer's act; it was fixed in 60f3cd4 and answered in its thread. Its headline asks for human confirmation that advertised aliases keep the resolved-directory boundary: the tests pinning that boundary are the symlink-escape, trimmed-sibling and retargeted-alias cases in server-roots.test.ts.

Treating this as a clean round: the loop ends here. Over to a maintainer.

cliffhall and others added 3 commits October 27, 2026 08:38
…s its realpath

parseRootUri kept only the realpath of each client Root, so a request that
used the Root's own spelling (a symlinked directory, /tmp on macOS) was
refused. It now returns both forms and getValidRootDirectories admits both,
deduplicated, as resolveAllowedDirectories does for command-line
directories. validatePath still realpaths every request, so a symlink
inside a Root that points outside it stays refused.

Closes #5183

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
normalizePath trims whitespace and quotes, which are legal POSIX filename
characters, so a Root advertised as 'alias ' would have allowed an
unrelated sibling 'alias'. Format the advertised form with
normalizePathFormat instead, and pin it with a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
…creating under it

A Root's advertised form can be a symlink, which can be retargeted after the
Roots load. resolveUnicodeEquivalentPath started its walk for a missing path
from the allowed directory's realpath without checking it, so write_file or
create_directory under a retargeted alias landed outside every allowed
directory. Check that starting realpath like each component below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall
cliffhall force-pushed the v2/fix/5183-filesystem-root-advertised-form branch from 60f3cd4 to b7471ab Compare October 27, 2026 12:43
@cliffhall

Copy link
Copy Markdown
Member Author

Rebased onto v2/main after #5318 (#5185), #5326 (#4162), #5325 (#5154) and #5324 merged. Two conflicts, both resolved mechanically:

No logic changed beyond adopting the merged helpers, so no new Copilot round. npm run local:gate exit 0 on the rebased head; every commit signed off.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

filesystem: a client Root is allowed only in its realpath form, unlike command-line directories

2 participants