Skip to content

Commit fe8446f

Browse files
committed
fix: resolve MCP marketplace visibility issue in WSL environments
- Add WSL detection utility to identify when running in WSL - Update MCP servers directory path logic to use Windows-compatible paths in WSL - Fix path normalization in McpHub for WSL environments - Add comprehensive tests for WSL detection and path handling This fix ensures that MCP servers appear correctly in the marketplace when running VS Code in WSL, matching the behavior in VirtualBox and native Windows environments. Fixes #7167
1 parent 185365a commit fe8446f

File tree

4 files changed

+348
-4
lines changed

4 files changed

+348
-4
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import os from "os"
22
import * as path from "path"
33
import fs from "fs/promises"
44
import EventEmitter from "events"
5+
import { isWSL, getWindowsHomeFromWSL } from "../../utils/wsl"
56

67
import { Anthropic } from "@anthropic-ai/sdk"
78
import delay from "delay"
@@ -1318,14 +1319,26 @@ export class ClineProvider
13181319
async ensureMcpServersDirectoryExists(): Promise<string> {
13191320
// Get platform-specific application data directory
13201321
let mcpServersDir: string
1321-
if (process.platform === "win32") {
1322-
// Windows: %APPDATA%\Roo-Code\MCP
1322+
1323+
// Check if we're running in WSL
1324+
if (isWSL()) {
1325+
// In WSL, use Windows paths for better compatibility
1326+
const windowsHome = getWindowsHomeFromWSL()
1327+
if (windowsHome) {
1328+
// Use Windows AppData directory accessible from WSL
1329+
mcpServersDir = path.join(windowsHome, "AppData", "Roaming", "Roo-Code", "MCP")
1330+
} else {
1331+
// Fallback to a WSL-friendly location that's easily accessible from Windows
1332+
mcpServersDir = path.join("/mnt/c", "ProgramData", "Roo-Code", "MCP")
1333+
}
1334+
} else if (process.platform === "win32") {
1335+
// Native Windows: %APPDATA%\Roo-Code\MCP
13231336
mcpServersDir = path.join(os.homedir(), "AppData", "Roaming", "Roo-Code", "MCP")
13241337
} else if (process.platform === "darwin") {
13251338
// macOS: ~/Documents/Cline/MCP
13261339
mcpServersDir = path.join(os.homedir(), "Documents", "Cline", "MCP")
13271340
} else {
1328-
// Linux: ~/.local/share/Cline/MCP
1341+
// Native Linux: ~/.local/share/Roo-Code/MCP
13291342
mcpServersDir = path.join(os.homedir(), ".local", "share", "Roo-Code", "MCP")
13301343
}
13311344

src/services/mcp/McpHub.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import * as path from "path"
1818
import * as vscode from "vscode"
1919
import { z } from "zod"
2020
import { t } from "../../i18n"
21+
import { isWSL } from "../../utils/wsl"
2122

2223
import { ClineProvider } from "../../core/webview/ClineProvider"
2324
import { GlobalFileNames } from "../../shared/globalFileNames"
@@ -1657,7 +1658,8 @@ export class McpHub {
16571658

16581659
// Normalize path for cross-platform compatibility
16591660
// Use a consistent path format for both reading and writing
1660-
const normalizedPath = process.platform === "win32" ? configPath.replace(/\\/g, "/") : configPath
1661+
// In WSL, we need to handle paths differently
1662+
const normalizedPath = process.platform === "win32" || isWSL() ? configPath.replace(/\\/g, "/") : configPath
16611663

16621664
// Read the appropriate config file
16631665
const content = await fs.readFile(normalizedPath, "utf-8")

src/utils/__tests__/wsl.spec.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
2+
import * as fs from "fs"
3+
import * as os from "os"
4+
import { isWSL, getWindowsHomeFromWSL } from "../wsl"
5+
6+
// Mock fs module
7+
vi.mock("fs", () => ({
8+
readFileSync: vi.fn(),
9+
accessSync: vi.fn(),
10+
constants: {
11+
F_OK: 0,
12+
},
13+
}))
14+
15+
// Mock os module
16+
vi.mock("os", () => ({
17+
userInfo: vi.fn(() => ({ username: "testuser" })),
18+
}))
19+
20+
describe("WSL Detection", () => {
21+
const originalEnv = process.env
22+
const originalPlatform = process.platform
23+
24+
beforeEach(() => {
25+
// Reset environment variables
26+
process.env = { ...originalEnv }
27+
// Reset platform
28+
Object.defineProperty(process, "platform", {
29+
value: originalPlatform,
30+
writable: true,
31+
})
32+
// Clear all mocks
33+
vi.clearAllMocks()
34+
})
35+
36+
afterEach(() => {
37+
// Restore original environment
38+
process.env = originalEnv
39+
Object.defineProperty(process, "platform", {
40+
value: originalPlatform,
41+
writable: false,
42+
})
43+
})
44+
45+
describe("isWSL", () => {
46+
it("should detect WSL when WSL_DISTRO_NAME is set", () => {
47+
process.env.WSL_DISTRO_NAME = "Ubuntu"
48+
expect(isWSL()).toBe(true)
49+
})
50+
51+
it("should detect WSL when WSL_INTEROP is set", () => {
52+
process.env.WSL_INTEROP = "/run/WSL/123_interop"
53+
expect(isWSL()).toBe(true)
54+
})
55+
56+
it("should detect WSL when /proc/version contains Microsoft", () => {
57+
Object.defineProperty(process, "platform", { value: "linux", writable: true })
58+
vi.mocked(fs.readFileSync).mockReturnValue(
59+
"Linux version 5.10.16.3-microsoft-standard-WSL2 (gcc version 9.3.0)",
60+
)
61+
expect(isWSL()).toBe(true)
62+
})
63+
64+
it("should detect WSL when /proc/version contains WSL", () => {
65+
Object.defineProperty(process, "platform", { value: "linux", writable: true })
66+
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.16.3-WSL2")
67+
expect(isWSL()).toBe(true)
68+
})
69+
70+
it("should detect WSL when /proc/sys/fs/binfmt_misc/WSLInterop exists", () => {
71+
Object.defineProperty(process, "platform", { value: "linux", writable: true })
72+
vi.mocked(fs.readFileSync).mockImplementation(() => {
73+
throw new Error("File not found")
74+
})
75+
// accessSync should not throw for WSLInterop file
76+
vi.mocked(fs.accessSync).mockImplementation((path) => {
77+
if (path === "/proc/sys/fs/binfmt_misc/WSLInterop") {
78+
return undefined
79+
}
80+
throw new Error("File not found")
81+
})
82+
expect(isWSL()).toBe(true)
83+
})
84+
85+
it("should detect WSL when PATH contains /mnt/c/", () => {
86+
Object.defineProperty(process, "platform", { value: "linux", writable: true })
87+
process.env.PATH = "/usr/bin:/mnt/c/Windows/System32:/mnt/c/Windows"
88+
vi.mocked(fs.readFileSync).mockImplementation(() => {
89+
throw new Error("File not found")
90+
})
91+
vi.mocked(fs.accessSync).mockImplementation(() => {
92+
throw new Error("File not found")
93+
})
94+
expect(isWSL()).toBe(true)
95+
})
96+
97+
it("should detect WSL when WSLENV is set", () => {
98+
Object.defineProperty(process, "platform", { value: "linux", writable: true })
99+
process.env.WSLENV = "WT_SESSION:WT_PROFILE_ID"
100+
vi.mocked(fs.readFileSync).mockImplementation(() => {
101+
throw new Error("File not found")
102+
})
103+
vi.mocked(fs.accessSync).mockImplementation(() => {
104+
throw new Error("File not found")
105+
})
106+
expect(isWSL()).toBe(true)
107+
})
108+
109+
it("should return false on native Windows", () => {
110+
Object.defineProperty(process, "platform", { value: "win32", writable: true })
111+
vi.mocked(fs.readFileSync).mockImplementation(() => {
112+
throw new Error("File not found")
113+
})
114+
vi.mocked(fs.accessSync).mockImplementation(() => {
115+
throw new Error("File not found")
116+
})
117+
expect(isWSL()).toBe(false)
118+
})
119+
120+
it("should return false on native Linux without WSL indicators", () => {
121+
Object.defineProperty(process, "platform", { value: "linux", writable: true })
122+
process.env.PATH = "/usr/bin:/usr/local/bin"
123+
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-generic")
124+
vi.mocked(fs.accessSync).mockImplementation(() => {
125+
throw new Error("File not found")
126+
})
127+
expect(isWSL()).toBe(false)
128+
})
129+
130+
it("should return false on macOS", () => {
131+
Object.defineProperty(process, "platform", { value: "darwin", writable: true })
132+
vi.mocked(fs.readFileSync).mockImplementation(() => {
133+
throw new Error("File not found")
134+
})
135+
vi.mocked(fs.accessSync).mockImplementation(() => {
136+
throw new Error("File not found")
137+
})
138+
expect(isWSL()).toBe(false)
139+
})
140+
})
141+
142+
describe("getWindowsHomeFromWSL", () => {
143+
beforeEach(() => {
144+
// Set up WSL environment
145+
Object.defineProperty(process, "platform", { value: "linux", writable: true })
146+
process.env.WSL_DISTRO_NAME = "Ubuntu"
147+
})
148+
149+
it("should return null when not in WSL", () => {
150+
delete process.env.WSL_DISTRO_NAME
151+
delete process.env.WSL_INTEROP
152+
Object.defineProperty(process, "platform", { value: "win32", writable: true })
153+
expect(getWindowsHomeFromWSL()).toBe(null)
154+
})
155+
156+
it("should find Windows home directory at /mnt/c/Users/username", () => {
157+
process.env.USER = "testuser"
158+
vi.mocked(fs.accessSync).mockImplementation((path) => {
159+
if (path === "/mnt/c/Users/testuser") {
160+
return undefined
161+
}
162+
throw new Error("File not found")
163+
})
164+
expect(getWindowsHomeFromWSL()).toBe("/mnt/c/Users/testuser")
165+
})
166+
167+
it("should find Windows home directory at /mnt/c/users/username (lowercase)", () => {
168+
process.env.USER = "testuser"
169+
vi.mocked(fs.accessSync).mockImplementation((path) => {
170+
if (path === "/mnt/c/users/testuser") {
171+
return undefined
172+
}
173+
throw new Error("File not found")
174+
})
175+
expect(getWindowsHomeFromWSL()).toBe("/mnt/c/users/testuser")
176+
})
177+
178+
it("should check D: drive if C: drive not found", () => {
179+
process.env.USER = "testuser"
180+
vi.mocked(fs.accessSync).mockImplementation((path) => {
181+
if (path === "/mnt/d/Users/testuser") {
182+
return undefined
183+
}
184+
throw new Error("File not found")
185+
})
186+
expect(getWindowsHomeFromWSL()).toBe("/mnt/d/Users/testuser")
187+
})
188+
189+
it("should use WSL_USER_NAME if available", () => {
190+
process.env.WSL_USER_NAME = "wsluser"
191+
vi.mocked(fs.accessSync).mockImplementation((path) => {
192+
if (path === "/mnt/c/Users/wsluser") {
193+
return undefined
194+
}
195+
throw new Error("File not found")
196+
})
197+
expect(getWindowsHomeFromWSL()).toBe("/mnt/c/Users/wsluser")
198+
})
199+
200+
it("should convert USERPROFILE Windows path to WSL path", () => {
201+
process.env.USERPROFILE = "C:\\Users\\winuser"
202+
vi.mocked(fs.accessSync).mockImplementation((path) => {
203+
if (path === "/mnt/c/Users/winuser") {
204+
return undefined
205+
}
206+
throw new Error("File not found")
207+
})
208+
expect(getWindowsHomeFromWSL()).toBe("/mnt/c/Users/winuser")
209+
})
210+
211+
it("should handle USERPROFILE with different drive letter", () => {
212+
process.env.USERPROFILE = "D:\\Users\\winuser"
213+
vi.mocked(fs.accessSync).mockImplementation((path) => {
214+
if (path === "/mnt/d/Users/winuser") {
215+
return undefined
216+
}
217+
throw new Error("File not found")
218+
})
219+
expect(getWindowsHomeFromWSL()).toBe("/mnt/d/Users/winuser")
220+
})
221+
222+
it("should return null when no Windows home directory is found", () => {
223+
process.env.USER = "testuser"
224+
delete process.env.USERPROFILE
225+
vi.mocked(fs.accessSync).mockImplementation(() => {
226+
throw new Error("File not found")
227+
})
228+
expect(getWindowsHomeFromWSL()).toBe(null)
229+
})
230+
})
231+
})

src/utils/wsl.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import * as fs from "fs"
2+
import * as os from "os"
3+
4+
/**
5+
* Detects if the current environment is running inside WSL (Windows Subsystem for Linux)
6+
* @returns true if running in WSL, false otherwise
7+
*/
8+
export function isWSL(): boolean {
9+
// WSL detection based on multiple indicators
10+
11+
// 1. Check for WSL environment variable (WSL 2)
12+
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
13+
return true
14+
}
15+
16+
// 2. Check for /proc/version containing Microsoft or WSL
17+
try {
18+
const procVersion = fs.readFileSync("/proc/version", "utf8")
19+
if (procVersion.toLowerCase().includes("microsoft") || procVersion.toLowerCase().includes("wsl")) {
20+
return true
21+
}
22+
} catch {
23+
// File doesn't exist or can't be read, not WSL
24+
}
25+
26+
// 3. Check for /proc/sys/fs/binfmt_misc/WSLInterop (WSL 2)
27+
try {
28+
fs.accessSync("/proc/sys/fs/binfmt_misc/WSLInterop", fs.constants.F_OK)
29+
return true
30+
} catch {
31+
// File doesn't exist, might not be WSL 2
32+
}
33+
34+
// 4. Check if running on Linux but with Windows-style paths in environment
35+
if (process.platform === "linux") {
36+
// Check for Windows paths in PATH environment variable
37+
const pathEnv = process.env.PATH || ""
38+
if (pathEnv.includes("/mnt/c/") || pathEnv.includes("\\")) {
39+
return true
40+
}
41+
42+
// Check for WSLENV variable (used for sharing environment variables between Windows and WSL)
43+
if (process.env.WSLENV) {
44+
return true
45+
}
46+
}
47+
48+
return false
49+
}
50+
51+
/**
52+
* Gets the Windows user home directory from within WSL
53+
* @returns The Windows home directory path or null if not in WSL or cannot determine
54+
*/
55+
export function getWindowsHomeFromWSL(): string | null {
56+
if (!isWSL()) {
57+
return null
58+
}
59+
60+
// Try to get Windows username from environment
61+
const windowsUsername = process.env.WSL_USER_NAME || process.env.USER || os.userInfo().username
62+
63+
// Common Windows home directory patterns in WSL
64+
const possiblePaths = [
65+
`/mnt/c/Users/${windowsUsername}`,
66+
`/mnt/c/users/${windowsUsername}`,
67+
`/mnt/d/Users/${windowsUsername}`,
68+
`/mnt/d/users/${windowsUsername}`,
69+
]
70+
71+
// Check which path exists
72+
for (const path of possiblePaths) {
73+
try {
74+
fs.accessSync(path, fs.constants.F_OK)
75+
return path
76+
} catch {
77+
// Path doesn't exist, try next
78+
}
79+
}
80+
81+
// Fallback: try to read from USERPROFILE if it's set (might be shared from Windows)
82+
if (process.env.USERPROFILE) {
83+
// Convert Windows path to WSL path (C:\Users\username -> /mnt/c/Users/username)
84+
const windowsPath = process.env.USERPROFILE
85+
const wslPath = windowsPath
86+
.replace(/^([A-Z]):/i, (_, drive) => `/mnt/${drive.toLowerCase()}`)
87+
.replace(/\\/g, "/")
88+
89+
try {
90+
fs.accessSync(wslPath, fs.constants.F_OK)
91+
return wslPath
92+
} catch {
93+
// Path doesn't exist
94+
}
95+
}
96+
97+
return null
98+
}

0 commit comments

Comments
 (0)