fix(claude-desktop): add native Windows support for MCP server import (#1653)

* fix(claude-desktop): add native Windows support for MCP server import

SUPPORTED_PLATFORMS in platform.ts only included 'macos' and 'wsl', causing getClaudeDesktopConfigPath() to throw on native Windows. Additionally, no Windows path handler existed -- the function only handled macOS then fell through to WSL-specific /mnt/c/Users/... paths.

Changes:
- Add 'windows' to SUPPORTED_PLATFORMS in platform.ts
- Add Windows path handler in getClaudeDesktopConfigPath() using %APPDATA%
- Update error messages to reflect Windows support

Impact: unblocks Claude Desktop MCP server import on native Windows.

* docs(claude-desktop): add docstrings to satisfy coverage threshold

* fix(claude-desktop): re-throw APPDATA error and add test coverage for Windows path

The APPDATA error was being swallowed by readClaudeDesktopMcpServers() catch-all, silently returning {} instead of surfacing the misconfigured environment to the user.

Changes:
- Re-throw APPDATA error in readClaudeDesktopMcpServers() so users see the configuration issue instead of silently getting no servers
- Add claudeDesktop.test.ts with tests for the Windows APPDATA path and missing-APPDATA error

Impact: users on Windows will now see a clear error if APPDATA is unset, instead of silently getting an empty server list.

* test(claude-desktop): use try/finally for env isolation and explicit fixture values

* fix(claude-desktop): opt-in import, update help text, mock platform in tests

* test(claude-desktop): save and restore real platform module to avoid leaking mock to other tests

mock.module is process-global in bun. Previous approach registered a mock for ./platform.js that affected all other test files importing from it, breaking the full test suite on CI. Now saves the real module via dynamic import before registering the mock, and restores it in afterAll.

* fix(claude-desktop): use win32.join for Windows APPDATA path to fix cross-platform test failure

path.join() on Linux uses forward slashes, producing mixed separators when joining a backslash-based APPDATA value. Since %APPDATA% is always a Windows path, use win32.join() which always uses backslashes regardless of host OS. Also update the test expectation to use win32.join() so it matches on any platform.

* test(claude-desktop): add readClaudeDesktopMcpServers rethrow test and fix env restore

- Added test for readClaudeDesktopMcpServers verifying APPDATA error surfaces instead of being swallowed
- Added restoreAppData helper that uses delete when original was undefined to avoid string coercion to 'undefined'
- Updated existing tests to use restoreAppData

* test(claude-desktop): remove mock.module to fix CI, add pure-logic path test

mock.module is process-global in bun and cannot be safely restored, causing getPlatform() to return 'windows' for all subsequent tests on CI — which triggered findGitBashPath() -> process.exit(1) on Linux.

- Remove mock.module('./platform.js', ...) entirely
- Guard Windows-dependent tests with if (process.platform !== 'win32') return
- Add pure-logic test using win32.join that validates path construction without any module mocking, works on all platforms

* refactor(claude-desktop): extract pure helper getWindowsClaudeDesktopConfigPath

Extract APPDATA path construction and missing-APPDATA error into a pure, synchronous helper function that takes appData as a parameter. This lets the core Windows logic be tested on any platform without mocking process.env, addressing CodeRabbit's concern that the previous if(isWindows) guard left changed behavior untested on CI.

- Two new unconditional tests exercise the helper on all OS runners
- if(isWindows) integration tests remain for end-to-end coverage on Windows
- No behavioral change

* revert opt-in import, update readClaudeDesktopMcpServers jsdoc

Addresses reviewer findings: - Revert defaultValue back to {t14} in MCPServerDesktopImportDialog — the opt-in UX change is scope creep for this Windows-support PR - Update readClaudeDesktopMcpServers JSDoc to document that it can throw on Windows when APPDATA is unset
This commit is contained in:
Ahmar Yaseen
2026-06-17 11:11:10 +08:00
committed by GitHub
parent a1b3346f65
commit e733908a91
4 changed files with 105 additions and 6 deletions
+1 -1
View File
@@ -3745,7 +3745,7 @@ async function run(): Promise<CommanderCommand> {
} = await import('./cli/handlers/mcp.js');
await mcpAddJsonHandler(name, json, options);
});
mcp.command('add-from-claude-desktop').description('Import MCP servers from Claude Desktop (Mac and WSL only)').option('-s, --scope <scope>', 'Configuration scope (local, user, or project)', 'local').action(async (options: {
mcp.command('add-from-claude-desktop').description('Import MCP servers from Claude Desktop (macOS, Windows, and WSL)').option('-s, --scope <scope>', 'Configuration scope (local, user, or project)', 'local').action(async (options: {
scope?: string;
}) => {
const {
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from 'bun:test'
import { win32 } from 'path'
import { getWindowsClaudeDesktopConfigPath } from './claudeDesktop.js'
const isWindows = process.platform === 'win32'
function restoreAppData(original: string | undefined): void {
if (original === undefined) {
delete process.env.APPDATA
} else {
process.env.APPDATA = original
}
}
test('getWindowsClaudeDesktopConfigPath constructs correct APPDATA path', () => {
const result = getWindowsClaudeDesktopConfigPath('C:\\Users\\test\\AppData\\Roaming')
expect(result).toBe('C:\\Users\\test\\AppData\\Roaming\\Claude\\claude_desktop_config.json')
})
test('getWindowsClaudeDesktopConfigPath throws when APPDATA is not set', () => {
expect(() => getWindowsClaudeDesktopConfigPath(undefined)).toThrow(
'APPDATA environment variable is not set.',
)
})
if (isWindows) {
const { getClaudeDesktopConfigPath, readClaudeDesktopMcpServers } = await import('./claudeDesktop.js')
test('getClaudeDesktopConfigPath delegates to helper when APPDATA is set', async () => {
const original = process.env.APPDATA
process.env.APPDATA = 'C:\\Users\\test\\AppData\\Roaming'
try {
const result = await getClaudeDesktopConfigPath()
expect(result).toBe(
win32.join('C:\\Users\\test\\AppData\\Roaming', 'Claude', 'claude_desktop_config.json'),
)
} finally {
restoreAppData(original)
}
})
test('getClaudeDesktopConfigPath throws via helper when APPDATA is unset', async () => {
const original = process.env.APPDATA
try {
delete process.env.APPDATA
await expect(getClaudeDesktopConfigPath()).rejects.toThrow(
'APPDATA environment variable is not set.',
)
} finally {
restoreAppData(original)
}
})
test('readClaudeDesktopMcpServers rethrows APPDATA error instead of swallowing it', async () => {
const original = process.env.APPDATA
try {
delete process.env.APPDATA
await expect(readClaudeDesktopMcpServers()).rejects.toThrow(
'APPDATA environment variable is not set.',
)
} finally {
restoreAppData(original)
}
})
}
+38 -4
View File
@@ -1,6 +1,6 @@
import { readdir, readFile, stat } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { join, win32 } from 'path'
import {
type McpServerConfig,
McpStdioServerConfigSchema,
@@ -10,12 +10,32 @@ import { safeParseJSON } from './json.js'
import { logError } from './log.js'
import { getPlatform, SUPPORTED_PLATFORMS } from './platform.js'
/**
* Constructs the Claude Desktop config path on Windows from the APPDATA
* environment variable value. Throws if appData is undefined or empty.
*/
export function getWindowsClaudeDesktopConfigPath(appData: string | undefined): string {
if (!appData) {
throw new Error('APPDATA environment variable is not set.')
}
return win32.join(appData, 'Claude', 'claude_desktop_config.json')
}
/**
* Resolves the path to the Claude Desktop configuration file based on the
* current platform. Supports macOS (~/Library/Application Support/Claude),
* native Windows (%APPDATA%/Claude), and WSL (/mnt/c/Users/...).
*
* Throws if the platform is not supported or if the required environment
* variable (%APPDATA%) is unset on Windows. The caller should handle these
* errors gracefully, as an absent config file is a normal state.
*/
export async function getClaudeDesktopConfigPath(): Promise<string> {
const platform = getPlatform()
if (!SUPPORTED_PLATFORMS.includes(platform)) {
throw new Error(
`Unsupported platform: ${platform} - Claude Desktop integration only works on macOS and WSL.`,
`Unsupported platform: ${platform} - Claude Desktop integration only works on macOS, Windows, and WSL.`,
)
}
@@ -29,7 +49,11 @@ export async function getClaudeDesktopConfigPath(): Promise<string> {
)
}
// First, try using USERPROFILE environment variable if available
if (platform === 'windows') {
return getWindowsClaudeDesktopConfigPath(process.env.APPDATA)
}
// WSL — try using USERPROFILE environment variable if available
const windowsHome = process.env.USERPROFILE
? process.env.USERPROFILE.replace(/\\/g, '/') // Convert Windows backslashes to forward slashes
: null
@@ -95,12 +119,19 @@ export async function getClaudeDesktopConfigPath(): Promise<string> {
)
}
/**
* Reads MCP server configurations from the Claude Desktop config file.
* Returns an empty record if the config file does not exist or cannot be
* parsed, making it safe to call without error handling at the call site
* on macOS and WSL. On Windows, may throw if the APPDATA environment
* variable is not set.
*/
export async function readClaudeDesktopMcpServers(): Promise<
Record<string, McpServerConfig>
> {
if (!SUPPORTED_PLATFORMS.includes(getPlatform())) {
throw new Error(
'Unsupported platform - Claude Desktop integration only works on macOS and WSL.',
'Unsupported platform - Claude Desktop integration only works on macOS, Windows, and WSL.',
)
}
try {
@@ -146,6 +177,9 @@ export async function readClaudeDesktopMcpServers(): Promise<
return servers
} catch (error) {
if (error instanceof Error && error.message.includes('APPDATA environment variable is not set.')) {
throw error
}
logError(error)
return {}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { logError } from './log.js'
export type Platform = 'macos' | 'windows' | 'wsl' | 'linux' | 'unknown'
export const SUPPORTED_PLATFORMS: Platform[] = ['macos', 'wsl']
export const SUPPORTED_PLATFORMS: Platform[] = ['macos', 'wsl', 'windows']
export const getPlatform = memoize((): Platform => {
try {