Repository navigation
filesystem: expandHome() does not handle Windows backslash separator (~\path) #5180
Description
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
- Run
@modelcontextprotocol/server-filesystemon Windows. - Supply an allowed directory argument or path with native Windows backslash tilde notation, e.g.,
~\projectsor~\Documents\notes.txt. expandHomeinsrc/filesystem/path-utils.tsexecutes:And similarly inexport function expandHome(filepath: string): string { if (filepath.startsWith("~/") || filepath === "~") { return path.join(os.homedir(), filepath.slice(1)); } return filepath; }
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
cliffhall commentedon Oct 19, 2026
Triage: Priority Low (total 5)
' and '/', so a Windows-style '\path' argument or root is not expanded and resolves to a literal '' directory under the working directory.buglabelStill 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.fix(filesystem): expand ~\ paths (Windows separator) in expandHome
fix(filesystem): expand ~\ paths (Windows separator) in expandHome
BOHAOYUAN commentedon Oct 20, 2026
Hi — I've opened a PR for this.
It makes
expandHome(and the root-URI expansion inroots-utils.ts) recognise~\alongside~/, so~\projectsexpands 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.BOHAOYUAN commentedon Oct 20, 2026
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~\testunchanged, sopath.resolve()later turns it into<cwd>/~/test.file://) toparseRootUriinroots-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:
Happy to test a maintainer-authored patch on Windows if that's helpful.
youlemei2022-web commentedon Oct 21, 2026
Worth pinning the scope of the fix:
expandHomeis only one of two places that see the tilde form. Even after it returnsC:\Users\x\projects, the allowed-directory check comparespath.resolve()output, so~/projectsand~\projectsmust normalize to the same string before that comparison - otherwise you fix expansion and still get "outside allowed directories" for the backslash form.Concrete shape:
p.replace(/^~(?=$|[\\/])/, homedir()), thenpath.resolve.process.platform === 'win32'; on POSIX~\is a legal filename character, and stripping it would be a behavior change.expandHome('~\\projects') === expandHome('~/projects')on Windows, plus one that both forms pass the roots check afterwards.chauvuusvn commentedon Oct 21, 2026
Following up on the V2 triage, while reviewing
expandHomeand 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 codeIn
roots-utils.ts:fileURLToPath("file:///~/projects")produces"/~/projects"(with a leading/). Therefore,rawPath.startsWith("~/")evaluates tofalse100% of the time, resolving to/<root>/~/projectsinstead of the user's home directory.fileURLToPath("file:///~/projects")throwsTypeError [ERR_INVALID_FILE_URL_PATH]: File URL path must be absolute. The catch block catches this and returnsnullbecauserootUri.startsWith("file://")is true.~/projectsor~\projects: It hits the catch block and callsnormalizePath(rootUri). However,normalizePath()never invokesexpandHome(), returning~/projectsunexpanded.2. Case-sensitivity false positives on Windows (
path-validation.ts)In
isPathWithinAllowedDirectories:While
normalizePath()capitalizes the drive letter (c:->C:), it leaves the directory path casing untouched. BecauseString.prototype.startsWith()is case-sensitive, paths on NTFS that differ only in casing (e.g., allowed directoryC:\Users\ChauVu\Workspacevs requested pathC:\Users\chauvu\Workspace\file.txt) fail validation and trigger false-positiveAccess denied - path outside allowed directories.3. Preprocessing order in
validatePath(lib.ts)validatePathcallsexpandHome(requestedPath)beforenormalizePath(). Stripping enclosing quotes and whitespace currently happens insidenormalizePath(). If an agent or client sends an argument with enclosing quotes ("~/foo") or whitespace (" ~/foo"),expandHomefails 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 onwin32) would cleanly resolve all these edge cases in one go.youlemei2022-web commentedon Oct 22, 2026
@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, sostartsWith("~/")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
expandHomeruns before quote and whitespace stripping,"~/foo"and~/foonever 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,normalizePathandparseRootUriall 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 thatstartsWith(dirWithSep)gets wrong, and you can gate the case fold onwin32without touching the string you hand back to the client.chauvuusvn commentedon Oct 22, 2026
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, andparseRootUri.To summarize the consensus for the V2 fix:
Canonical Pipeline Order:
"..."/ `'...').p.replace(/^~(?=$|[\\/])/, os.homedir()), gating backslash\\specifically onprocess.platform === 'win32'to preserve valid POSIX filenames.path.resolve().Root Boundaries via
path.relative():startsWith()withconst 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.Coverage:
~/...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.