Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/filesystem-expandhome-backslash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/server-filesystem": patch
---

Expand `~\` to the home directory on Windows, in command-line directories, request paths and client Roots alike, as `~/` already is. On POSIX a backslash is a filename character, so `~\name` there still names a literal file.
13 changes: 13 additions & 0 deletions src/filesystem/__tests__/path-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ describe("Path Utilities", () => {
it("leaves other paths unchanged", () => {
expect(expandHome("C:/test")).toBe("C:/test");
});

// On POSIX a backslash is a filename character, so ~\notes names a
// file in the working directory, not one in the home directory (#5180).
// win32.test.ts covers the Windows expansion.
it("leaves ~\\ alone on POSIX", () => {
const realPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "linux" });
try {
expect(expandHome("~\\notes")).toBe("~\\notes");
} finally {
Object.defineProperty(process, "platform", { value: realPlatform });
}
});
});

describe("WSL path handling (issue #2795 fix)", () => {
Expand Down
45 changes: 44 additions & 1 deletion src/filesystem/__tests__/win32.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
// Windows-only branches of path-utils.ts and path-validation.ts run here:
// drive roots, bare drive letters, backslash conversion and UNC shares.

import { promises as fsp, type Stats } from "fs";
import os from "os";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";

vi.mock("path", async () => {
const actual = await vi.importActual<typeof import("path")>("path");
return { ...actual.win32, default: actual.win32 };
});

const { normalizePath } = await import("../path-utils.js");
const { normalizePath, expandHome } = await import("../path-utils.js");
const { getValidRootDirectories } = await import("../roots-utils.js");
const { isPathWithinAllowedDirectories } =
await import("../path-validation.js");

Expand Down Expand Up @@ -141,3 +144,43 @@ describe("isPathWithinAllowedDirectories on win32", () => {
).toBe(false);
});
});

// Windows users write a home-relative path with a backslash (#5180). The
// home directory is stubbed to a Windows spelling, since the host's is POSIX.
describe("~\\ on win32", () => {
const home = "C:\\Users\\me";

it("expands ~\\ to the home directory, as ~/ is", () => {
const spy = vi.spyOn(os, "homedir").mockReturnValue(home);
try {
expect(expandHome("~\\projects")).toBe("C:\\Users\\me\\projects");
expect(expandHome("~\\projects")).toBe(expandHome("~/projects"));
} finally {
spy.mockRestore();
}
});

it("leaves a ~ that does not start a segment alone", () => {
expect(expandHome("~user\\projects")).toBe("~user\\projects");
expect(expandHome("C:\\~\\x")).toBe("C:\\~\\x");
});

it("expands a ~\\ client Root through the same expandHome", async () => {
const spies = [
vi.spyOn(os, "homedir").mockReturnValue(home),
// The directory does not exist on this host: realpath and stat answer
// as Windows would for an existing directory.
vi.spyOn(fsp, "realpath").mockImplementation(async (p) => String(p)),
vi
.spyOn(fsp, "stat")
.mockResolvedValue({ isDirectory: () => true } as Stats),
];
try {
expect(await getValidRootDirectories([{ uri: "~\\projects" }])).toEqual([
"C:\\Users\\me\\projects",
]);
} finally {
for (const spy of spies) spy.mockRestore();
}
});
});
10 changes: 8 additions & 2 deletions src/filesystem/path-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,18 @@ export function normalizePathFormat(p: string): string {
}

/**
* Expands home directory tildes in paths
* Expands home directory tildes in paths: a bare `~`, `~/…` and, on Windows
* only, `~\…` (#5180). On POSIX a backslash is an ordinary filename
* character, so `~\notes` there names a file and is left as it is.
* @param filepath The path to expand
* @returns Expanded path
*/
export function expandHome(filepath: string): string {
if (filepath.startsWith("~/") || filepath === "~") {
if (
filepath === "~" ||
filepath.startsWith("~/") ||
(process.platform === "win32" && filepath.startsWith("~\\"))
) {
return path.join(os.homedir(), filepath.slice(1));
}
return filepath;
Expand Down
12 changes: 6 additions & 6 deletions src/filesystem/roots-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { promises as fs, type Stats } from "fs";
import path from "path";
import os from "os";
import { normalizePathFormat, realpathWithRetry } from "./path-utils.js";
import {
normalizePathFormat,
realpathWithRetry,
expandHome,
} from "./path-utils.js";
import type { Root } from "@modelcontextprotocol/server";
import { fileURLToPath } from "url";

Expand All @@ -25,10 +28,7 @@ async function parseRootUri(rootUri: string): Promise<RootPaths | null> {
const rawPath = rootUri.startsWith("file://")
? fileURLToPath(rootUri)
: rootUri;
const expandedPath =
rawPath.startsWith("~/") || rawPath === "~"
? path.join(os.homedir(), rawPath.slice(1))
: rawPath;
const expandedPath = expandHome(rawPath);
const absolutePath = path.resolve(expandedPath);
const resolvedPath = await realpathWithRetry(absolutePath);
return {
Expand Down
Loading