Skip to content

Commit 923e249

Browse files
committed
fix: resolve MCP server execution on Windows with node version managers (#1246)
- Wrap commands with cmd.exe /c on Windows to handle non-exe executables - Add platform detection for Windows-specific command handling - Add comprehensive tests for Windows command wrapping behavior - Maintain backward compatibility for non-Windows platforms This fixes the 'spawn npx ENOENT' error that occurs when using node version managers like fnm on Windows, where npx is a PowerShell script rather than an executable file.
1 parent 54edab5 commit 923e249

File tree

2 files changed

+168
-2
lines changed

2 files changed

+168
-2
lines changed

src/services/mcp/McpHub.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,9 +586,16 @@ export class McpHub {
586586
})) as typeof config
587587

588588
if (configInjected.type === "stdio") {
589+
// On Windows, wrap commands with cmd.exe to handle non-exe executables like npx.ps1
590+
const isWindows = process.platform === "win32"
591+
const command = isWindows ? "cmd.exe" : configInjected.command
592+
const args = isWindows
593+
? ["/c", configInjected.command, ...(configInjected.args || [])]
594+
: configInjected.args
595+
589596
transport = new StdioClientTransport({
590-
command: configInjected.command,
591-
args: configInjected.args,
597+
command,
598+
args,
592599
cwd: configInjected.cwd,
593600
env: {
594601
...getDefaultEnvironment(),

src/services/mcp/__tests__/McpHub.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,23 @@ jest.mock("vscode", () => ({
3333
jest.mock("fs/promises")
3434
jest.mock("../../../core/webview/ClineProvider")
3535

36+
// Mock the MCP SDK modules
37+
jest.mock("@modelcontextprotocol/sdk/client/stdio", () => ({
38+
StdioClientTransport: jest.fn(),
39+
getDefaultEnvironment: jest.fn().mockReturnValue({ PATH: "/usr/bin" }),
40+
}))
41+
42+
jest.mock("@modelcontextprotocol/sdk/client/index", () => ({
43+
Client: jest.fn(),
44+
}))
45+
3646
describe("McpHub", () => {
3747
let mcpHub: McpHubType
3848
let mockProvider: Partial<ClineProvider>
3949

4050
// Store original console methods
4151
const originalConsoleError = console.error
52+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
4253

4354
beforeEach(() => {
4455
jest.clearAllMocks()
@@ -113,6 +124,10 @@ describe("McpHub", () => {
113124
afterEach(() => {
114125
// Restore original console methods
115126
console.error = originalConsoleError
127+
// Restore original platform
128+
if (originalPlatform) {
129+
Object.defineProperty(process, "platform", originalPlatform)
130+
}
116131
})
117132

118133
describe("toggleToolAlwaysAllow", () => {
@@ -684,4 +699,148 @@ describe("McpHub", () => {
684699
})
685700
})
686701
})
702+
703+
describe("Windows command wrapping", () => {
704+
let StdioClientTransport: jest.Mock
705+
let Client: jest.Mock
706+
707+
beforeEach(() => {
708+
// Reset mocks
709+
jest.clearAllMocks()
710+
711+
// Get references to the mocked constructors
712+
StdioClientTransport = require("@modelcontextprotocol/sdk/client/stdio").StdioClientTransport as jest.Mock
713+
Client = require("@modelcontextprotocol/sdk/client/index").Client as jest.Mock
714+
715+
// Mock Windows platform
716+
Object.defineProperty(process, "platform", {
717+
value: "win32",
718+
writable: true,
719+
enumerable: true,
720+
configurable: true,
721+
})
722+
})
723+
724+
it("should wrap commands with cmd.exe on Windows", async () => {
725+
// Mock StdioClientTransport
726+
const mockTransport = {
727+
start: jest.fn().mockResolvedValue(undefined),
728+
close: jest.fn().mockResolvedValue(undefined),
729+
stderr: {
730+
on: jest.fn(),
731+
},
732+
onerror: null,
733+
onclose: null,
734+
}
735+
736+
StdioClientTransport.mockImplementation((config: any) => {
737+
// Verify that cmd.exe wrapping is applied
738+
expect(config.command).toBe("cmd.exe")
739+
expect(config.args).toEqual([
740+
"/c",
741+
"npx",
742+
"-y",
743+
"@modelcontextprotocol/server-filesystem",
744+
"/test/path",
745+
])
746+
return mockTransport
747+
})
748+
749+
// Mock Client
750+
Client.mockImplementation(() => ({
751+
connect: jest.fn().mockResolvedValue(undefined),
752+
close: jest.fn().mockResolvedValue(undefined),
753+
getInstructions: jest.fn().mockReturnValue("test instructions"),
754+
request: jest.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
755+
}))
756+
757+
// Create a new McpHub instance
758+
const mcpHub = new McpHub(mockProvider as ClineProvider)
759+
760+
// Mock the config file read
761+
;(fs.readFile as jest.Mock).mockResolvedValue(
762+
JSON.stringify({
763+
mcpServers: {
764+
"test-npx-server": {
765+
command: "npx",
766+
args: ["-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
767+
},
768+
},
769+
}),
770+
)
771+
772+
// Initialize servers (this will trigger connectToServer)
773+
await mcpHub["initializeGlobalMcpServers"]()
774+
775+
// Verify StdioClientTransport was called with wrapped command
776+
expect(StdioClientTransport).toHaveBeenCalledWith(
777+
expect.objectContaining({
778+
command: "cmd.exe",
779+
args: ["/c", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
780+
}),
781+
)
782+
})
783+
784+
it("should not wrap commands on non-Windows platforms", async () => {
785+
// Mock non-Windows platform
786+
Object.defineProperty(process, "platform", {
787+
value: "darwin",
788+
writable: true,
789+
enumerable: true,
790+
configurable: true,
791+
})
792+
793+
// Mock StdioClientTransport
794+
const mockTransport = {
795+
start: jest.fn().mockResolvedValue(undefined),
796+
close: jest.fn().mockResolvedValue(undefined),
797+
stderr: {
798+
on: jest.fn(),
799+
},
800+
onerror: null,
801+
onclose: null,
802+
}
803+
804+
StdioClientTransport.mockImplementation((config: any) => {
805+
// Verify that no cmd.exe wrapping is applied
806+
expect(config.command).toBe("npx")
807+
expect(config.args).toEqual(["-y", "@modelcontextprotocol/server-filesystem", "/test/path"])
808+
return mockTransport
809+
})
810+
811+
// Mock Client
812+
Client.mockImplementation(() => ({
813+
connect: jest.fn().mockResolvedValue(undefined),
814+
close: jest.fn().mockResolvedValue(undefined),
815+
getInstructions: jest.fn().mockReturnValue("test instructions"),
816+
request: jest.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
817+
}))
818+
819+
// Create a new McpHub instance
820+
const mcpHub = new McpHub(mockProvider as ClineProvider)
821+
822+
// Mock the config file read
823+
;(fs.readFile as jest.Mock).mockResolvedValue(
824+
JSON.stringify({
825+
mcpServers: {
826+
"test-npx-server": {
827+
command: "npx",
828+
args: ["-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
829+
},
830+
},
831+
}),
832+
)
833+
834+
// Initialize servers (this will trigger connectToServer)
835+
await mcpHub["initializeGlobalMcpServers"]()
836+
837+
// Verify StdioClientTransport was called without wrapping
838+
expect(StdioClientTransport).toHaveBeenCalledWith(
839+
expect.objectContaining({
840+
command: "npx",
841+
args: ["-y", "@modelcontextprotocol/server-filesystem", "/test/path"],
842+
}),
843+
)
844+
})
845+
})
687846
})

0 commit comments

Comments
 (0)