Skip to content

filesystem: expandHome() does not handle Windows backslash separator (~\path) #5180

Open

Description

@chauvuusvn

Which server?

filesystem (@modelcontextprotocol/server-filesystem)

Server version

latest (v2/main)

How did you run the server?

npx (npm package)

Transport

stdio

Protocol era

Modern (2026-07-28)

MCP client and version

Claude Desktop / MCP Inspector

Operating system and runtime

Windows 11 / Windows Server, Node.js v20+ / v22+

Server configuration

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "~\\projects"]
    }
  }
}

Steps to reproduce

  1. Run @modelcontextprotocol/server-filesystem on Windows.
  2. Supply an allowed directory argument or path with native Windows backslash tilde notation, e.g., ~\projects or ~\Documents\notes.txt.
  3. expandHome in src/filesystem/path-utils.ts executes:
    export function expandHome(filepath: string): string {
        if (filepath.startsWith("~/") || filepath === "~") {
            return path.join(os.homedir(), filepath.slice(1));
        }
        return filepath;
    }
    And similarly in src/filesystem/roots-utils.ts:
    if (rawPath.startsWith("~/") || rawPath === "~") { ... }

Expected behavior

On Windows platforms, tilde followed by a backslash (~\) is the natural path separator convention when invoking CLI commands or passing configurations (e.g. ~\workspace). expandHome should recognize both ~/ and ~\ prefixes and expand them to os.homedir().

Actual behavior

Because the condition strictly checks .startsWith("~/"), any path formatted with backslashes (such as ~\projects or ~\\projects) fails to match. expandHome returns the raw string unchanged.

Downstream, path.resolve() interprets ~ as a literal directory name relative to the current working directory (e.g. C:\current\dir\~\projects), causing directory validation to fail with:
Access denied - Path does not exist or is outside allowed directories

Logs or error output

Node.js REPL test:
> const path = require('path');
> const os = require('os');
> function expandHome(p) { if (p.startsWith('~/') || p === '~') return path.join(os.homedir(), p.slice(1)); return p; }

> expandHome('~/test')
'C:\\Users\\User\\test'   // OK (POSIX slash)

> expandHome('~\\test')
'~\\test'                 // Fails to expand Windows backslash

Already prototyped a fix?

Update expandHome in src/filesystem/path-utils.ts and roots-utils.ts to recognize ~\:

export function expandHome(filepath: string): string {
    if (filepath === "~" || filepath.startsWith("~/") || filepath.startsWith("~\\")) {
        return path.join(os.homedir(), filepath.slice(1));
    }
    return filepath;
}

Before you submit

  • I searched existing issues and this is not a duplicate.
  • This is not a security vulnerability report (those go through the private advisory form).

Activity

  1. added
    bugSomething isn't working
    server-filesystemReference implementation for the Filesystem MCP server - src/filesystem
    on Oct 19, 2026
  2. cliffhall commented on Oct 19, 2026

    @cliffhall
    Member

    Triage: Priority Low (total 5)

    • Severity 2: expandHome and the roots parser expand only '' and '/', so a Windows-style '\path' argument or root is not expanded and resolves to a literal '' directory under the working directory.
    • Urgency 2: wanted eventually
    • Bonuses: +1 bug label

    Still present on v2 (checked by reading the current code).

    Board: Servers V2 (#43), Status Incoming, awaiting maintainer review (no milestone yet). Part of the backlog triage in #4875.

  3. added 2 commits that reference this issue on Oct 20, 2026
    41b766b
    4c67269
  4. BOHAOYUAN commented on Oct 20, 2026

    @BOHAOYUAN

    Hi — I've opened a PR for this.

    It makes expandHome (and the root-URI expansion in roots-utils.ts) recognise ~\ alongside ~/, so ~\projects expands to the home directory instead of being resolved as a literal ~ directory relative to cwd.

    Small diff: 2 logic lines + 1 test case. I verified the behaviour on Windows, where expandHome("~\\test") now returns the expected expanded path.

    Happy to adjust the approach (e.g. gating on process.platform) if you'd prefer that.

  5. BOHAOYUAN commented on Oct 20, 2026

    @BOHAOYUAN

    Correction on my previous comment: I'd missed that CONTRIBUTING.md asks for issues rather than pull requests here, so I am not opening a PR — the patch stays on my fork.

    In case the reproduction detail is useful for the board, here is what I confirmed on Windows 11 (Node 22, @modelcontextprotocol/server-filesystem):

    • expandHome("~\test") returns ~\test unchanged, so path.resolve() later turns it into <cwd>/~/test.
    • Same for a root URI passed as a plain path (not file://) to parseRootUri in roots-utils.ts, which duplicates the ~/ check.
    • expandHome("C:/test"), relative paths and ~ alone behave correctly today, so the change is a two-line predicate widening (~/ → ~/ or ~\) plus one test case.

    For the prompt-driven workflow, the prompt that produced my local patch:

    In src/filesystem/path-utils.ts, expandHome only treats ~/ and a bare ~ as home-relative, so ~\path (Windows separator) is returned unchanged and later resolves to a literal ~ directory under cwd. Make ~\ expand to the home directory as well, and apply the same predicate fix to the duplicated root-URI expansion in src/filesystem/roots-utils.ts. Add a case to __tests__/path-utils.test.ts asserting expandHome("~\test") starts with os.homedir() and contains no ~. Leave all other paths untouched.

    Happy to test a maintainer-authored patch on Windows if that's helpful.

  6. youlemei2022-web commented on Oct 21, 2026

    @youlemei2022-web

    Worth pinning the scope of the fix: expandHome is only one of two places that see the tilde form. Even after it returns C:\Users\x\projects, the allowed-directory check compares path.resolve() output, so ~/projects and ~\projects must normalize to the same string before that comparison - otherwise you fix expansion and still get "outside allowed directories" for the backslash form.

    Concrete shape:

    • Normalize once at the boundary: p.replace(/^~(?=$|[\\/])/, homedir()), then path.resolve.
    • Gate the backslash branch on process.platform === 'win32'; on POSIX ~\ is a legal filename character, and stripping it would be a behavior change.
    • Add a regression asserting expandHome('~\\projects') === expandHome('~/projects') on Windows, plus one that both forms pass the roots check afterwards.
  7. chauvuusvn commented on Oct 21, 2026

    @chauvuusvn
    Author

    Following up on the V2 triage, while reviewing expandHome and the path-resolution pipeline, there are a few related edge-cases in the current codebase that compound this issue and would be worth addressing together in V2:

    1. parseRootUri (roots-utils.ts): Tilde expansion logic is unreachable dead code

    In roots-utils.ts:

    const rawPath = fileURLToPath(rootUri);
    const expandedPath =
      rawPath.startsWith("~/") || rawPath === "~"
        ? path.join(os.homedir(), rawPath.slice(1))
        : rawPath;
    • On POSIX: fileURLToPath("file:///~/projects") produces "/~/projects" (with a leading /). Therefore, rawPath.startsWith("~/") evaluates to false 100% of the time, resolving to /<root>/~/projects instead of the user's home directory.
    • On Win32: fileURLToPath("file:///~/projects") throws TypeError [ERR_INVALID_FILE_URL_PATH]: File URL path must be absolute. The catch block catches this and returns null because rootUri.startsWith("file://") is true.
    • If a client passes a raw non-URI string like ~/projects or ~\projects: It hits the catch block and calls normalizePath(rootUri). However, normalizePath() never invokes expandHome(), returning ~/projects unexpanded.

    2. Case-sensitivity false positives on Windows (path-validation.ts)

    In isPathWithinAllowedDirectories:

    const dirWithSep = normalizedDir.endsWith(path.sep)
      ? normalizedDir
      : normalizedDir + path.sep;
    return normalizedPath.startsWith(dirWithSep);

    While normalizePath() capitalizes the drive letter (c: -> C:), it leaves the directory path casing untouched. Because String.prototype.startsWith() is case-sensitive, paths on NTFS that differ only in casing (e.g., allowed directory C:\Users\ChauVu\Workspace vs requested path C:\Users\chauvu\Workspace\file.txt) fail validation and trigger false-positive Access denied - path outside allowed directories.

    3. Preprocessing order in validatePath (lib.ts)

    validatePath calls expandHome(requestedPath) before normalizePath(). Stripping enclosing quotes and whitespace currently happens inside normalizePath(). If an agent or client sends an argument with enclosing quotes ("~/foo") or whitespace (" ~/foo"), expandHome fails to match the prefix.

    A unified regex normalization before path resolution like filepath.trim().replace(/^["']|["']$/g, "").replace(/^~(?=$|[\\/])/, os.homedir()) (along with case-insensitive boundary checks on win32) would cleanly resolve all these edge cases in one go.

  8. youlemei2022-web commented on Oct 22, 2026

    @youlemei2022-web

    @chauvuusvn Good catch on parseRootUri, and it points at the real problem: tilde handling is spread across three functions with different preconditions. Two additions.

    On (1), the POSIX case is worse than dead code. fileURLToPath("file:///~/projects") yields /~/projects, so startsWith("~/") never matches and the result is /<root>/~/projects. That either fails the roots check or, if the process cwd happens to be /, silently resolves into the real ~/projects. Silent beats loud only when the outcome is correct.

    On (3), ordering matters more than the regex. If expandHome runs before quote and whitespace stripping, "~/foo" and ~/foo never reach the tilde branch, so the unified regex has to sit at the top of the pipeline: trim, strip one layer of matching quotes, expand tilde at a segment boundary, path.resolve, and only then fold case for comparison. validatePath, normalizePath and parseRootUri all need to call that single helper. Fixing one entry point leaves the other two disagreeing, which is how you get a path that passes validation and then fails on open.

    On (2), toLowerCase() on both sides is the cheap fix but it changes semantics on case-sensitive directories on NTFS, and it still misses 8.3 short names. path.relative(allowedDir, target) plus a .. check handles the trailing separator and \\?\ cases that startsWith(dirWithSep) gets wrong, and you can gate the case fold on win32 without touching the string you hand back to the client.

  9. chauvuusvn commented on Oct 22, 2026

    @chauvuusvn
    Author

    Thanks @youlemei2022-web — completely agreed on all points.

    Consolidating path resolution into a single canonical helper at the entry boundary eliminates the divergence between validatePath, normalizePath, and parseRootUri.

    To summarize the consensus for the V2 fix:

    1. Canonical Pipeline Order:

      • Trim whitespace.
      • Strip matching outer quotes ("..." / `'...').
      • Boundary tilde expansion: p.replace(/^~(?=$|[\\/])/, os.homedir()), gating backslash \\ specifically on process.platform === 'win32' to preserve valid POSIX filenames.
      • path.resolve().
    2. Root Boundaries via path.relative():

      • Replacing naive startsWith() with const rel = path.relative(allowedDir, target); and validating !rel.startsWith('..') && !path.isAbsolute(rel) cleanly handles trailing slashes, \\?\\ extended-length prefixes, and case normalization without mutating client-returned strings.
    3. Coverage:

      • Add cross-platform regressions asserting parity between ~/... and ~\\... on Win32, and ensuring unexpanded POSIX URIs (file:///~/...) do not silently alias into cwd.

    Happy to open a targeted PR against the V2 branch with this unified helper and test coverage, or coordinate with the maintainers when V2 backlog (#4875) kicks off.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingserver-filesystemReference implementation for the Filesystem MCP server - src/filesystemv2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions