Skip to content

Commit 00996bc

Browse files
committed
fix: address review feedback - prevent double-wrapping and add tests
1 parent 923e249 commit 00996bc

File tree

2 files changed

+217
-4
lines changed

2 files changed

+217
-4
lines changed

src/services/mcp/McpHub.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -587,11 +587,20 @@ export class McpHub {
587587

588588
if (configInjected.type === "stdio") {
589589
// On Windows, wrap commands with cmd.exe to handle non-exe executables like npx.ps1
590+
// This is necessary for node version managers (fnm, nvm-windows, volta) that implement
591+
// commands as PowerShell scripts rather than executables.
592+
// Note: This adds a small overhead as commands go through an additional shell layer.
590593
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
594+
595+
// Check if command is already cmd.exe to avoid double-wrapping
596+
const isAlreadyWrapped =
597+
configInjected.command.toLowerCase() === "cmd.exe" || configInjected.command.toLowerCase() === "cmd"
598+
599+
const command = isWindows && !isAlreadyWrapped ? "cmd.exe" : configInjected.command
600+
const args =
601+
isWindows && !isAlreadyWrapped
602+
? ["/c", configInjected.command, ...(configInjected.args || [])]
603+
: configInjected.args
595604

596605
transport = new StdioClientTransport({
597606
command,

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

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,5 +842,209 @@ describe("McpHub", () => {
842842
}),
843843
)
844844
})
845+
846+
it("should not double-wrap commands that are already cmd.exe", async () => {
847+
// Mock Windows platform
848+
Object.defineProperty(process, "platform", {
849+
value: "win32",
850+
writable: true,
851+
enumerable: true,
852+
configurable: true,
853+
})
854+
855+
// Mock StdioClientTransport
856+
const mockTransport = {
857+
start: jest.fn().mockResolvedValue(undefined),
858+
close: jest.fn().mockResolvedValue(undefined),
859+
stderr: {
860+
on: jest.fn(),
861+
},
862+
onerror: null,
863+
onclose: null,
864+
}
865+
866+
StdioClientTransport.mockImplementation((config: any) => {
867+
// Verify that cmd.exe is not double-wrapped
868+
expect(config.command).toBe("cmd.exe")
869+
expect(config.args).toEqual(["/c", "echo", "test"])
870+
return mockTransport
871+
})
872+
873+
// Mock Client
874+
Client.mockImplementation(() => ({
875+
connect: jest.fn().mockResolvedValue(undefined),
876+
close: jest.fn().mockResolvedValue(undefined),
877+
getInstructions: jest.fn().mockReturnValue("test instructions"),
878+
request: jest.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
879+
}))
880+
881+
// Create a new McpHub instance
882+
const mcpHub = new McpHub(mockProvider as ClineProvider)
883+
884+
// Mock the config file read with cmd.exe already as command
885+
;(fs.readFile as jest.Mock).mockResolvedValue(
886+
JSON.stringify({
887+
mcpServers: {
888+
"test-cmd-server": {
889+
command: "cmd.exe",
890+
args: ["/c", "echo", "test"],
891+
},
892+
},
893+
}),
894+
)
895+
896+
// Initialize servers (this will trigger connectToServer)
897+
await mcpHub["initializeGlobalMcpServers"]()
898+
899+
// Verify StdioClientTransport was called without double-wrapping
900+
expect(StdioClientTransport).toHaveBeenCalledWith(
901+
expect.objectContaining({
902+
command: "cmd.exe",
903+
args: ["/c", "echo", "test"],
904+
}),
905+
)
906+
})
907+
908+
it("should handle npx.ps1 scenario from node version managers", async () => {
909+
// Mock Windows platform
910+
Object.defineProperty(process, "platform", {
911+
value: "win32",
912+
writable: true,
913+
enumerable: true,
914+
configurable: true,
915+
})
916+
917+
// Mock StdioClientTransport to simulate the ENOENT error without wrapping
918+
const mockTransport = {
919+
start: jest.fn().mockResolvedValue(undefined),
920+
close: jest.fn().mockResolvedValue(undefined),
921+
stderr: {
922+
on: jest.fn(),
923+
},
924+
onerror: null,
925+
onclose: null,
926+
}
927+
928+
let callCount = 0
929+
StdioClientTransport.mockImplementation((config: any) => {
930+
callCount++
931+
// First call would fail with ENOENT if not wrapped
932+
// Second call should be wrapped with cmd.exe
933+
if (callCount === 1) {
934+
// This simulates what would happen without wrapping
935+
expect(config.command).toBe("cmd.exe")
936+
expect(config.args[0]).toBe("/c")
937+
expect(config.args[1]).toBe("npx")
938+
}
939+
return mockTransport
940+
})
941+
942+
// Mock Client
943+
Client.mockImplementation(() => ({
944+
connect: jest.fn().mockResolvedValue(undefined),
945+
close: jest.fn().mockResolvedValue(undefined),
946+
getInstructions: jest.fn().mockReturnValue("test instructions"),
947+
request: jest.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
948+
}))
949+
950+
// Create a new McpHub instance
951+
const mcpHub = new McpHub(mockProvider as ClineProvider)
952+
953+
// Mock the config file read - simulating fnm/nvm-windows scenario
954+
;(fs.readFile as jest.Mock).mockResolvedValue(
955+
JSON.stringify({
956+
mcpServers: {
957+
"test-fnm-npx-server": {
958+
command: "npx",
959+
args: ["-y", "@modelcontextprotocol/server-example"],
960+
env: {
961+
// Simulate fnm environment
962+
FNM_DIR: "C:\\Users\\test\\.fnm",
963+
FNM_NODE_DIST_MIRROR: "https://nodejs.org/dist",
964+
FNM_ARCH: "x64",
965+
},
966+
},
967+
},
968+
}),
969+
)
970+
971+
// Initialize servers (this will trigger connectToServer)
972+
await mcpHub["initializeGlobalMcpServers"]()
973+
974+
// Verify that the command was wrapped with cmd.exe
975+
expect(StdioClientTransport).toHaveBeenCalledWith(
976+
expect.objectContaining({
977+
command: "cmd.exe",
978+
args: ["/c", "npx", "-y", "@modelcontextprotocol/server-example"],
979+
env: expect.objectContaining({
980+
FNM_DIR: "C:\\Users\\test\\.fnm",
981+
FNM_NODE_DIST_MIRROR: "https://nodejs.org/dist",
982+
FNM_ARCH: "x64",
983+
}),
984+
}),
985+
)
986+
})
987+
988+
it("should handle case-insensitive cmd command check", async () => {
989+
// Mock Windows platform
990+
Object.defineProperty(process, "platform", {
991+
value: "win32",
992+
writable: true,
993+
enumerable: true,
994+
configurable: true,
995+
})
996+
997+
// Mock StdioClientTransport
998+
const mockTransport = {
999+
start: jest.fn().mockResolvedValue(undefined),
1000+
close: jest.fn().mockResolvedValue(undefined),
1001+
stderr: {
1002+
on: jest.fn(),
1003+
},
1004+
onerror: null,
1005+
onclose: null,
1006+
}
1007+
1008+
StdioClientTransport.mockImplementation((config: any) => {
1009+
// Verify that CMD (uppercase) is not double-wrapped
1010+
expect(config.command).toBe("CMD")
1011+
expect(config.args).toEqual(["/c", "echo", "test"])
1012+
return mockTransport
1013+
})
1014+
1015+
// Mock Client
1016+
Client.mockImplementation(() => ({
1017+
connect: jest.fn().mockResolvedValue(undefined),
1018+
close: jest.fn().mockResolvedValue(undefined),
1019+
getInstructions: jest.fn().mockReturnValue("test instructions"),
1020+
request: jest.fn().mockResolvedValue({ tools: [], resources: [], resourceTemplates: [] }),
1021+
}))
1022+
1023+
// Create a new McpHub instance
1024+
const mcpHub = new McpHub(mockProvider as ClineProvider)
1025+
1026+
// Mock the config file read with CMD (uppercase) as command
1027+
;(fs.readFile as jest.Mock).mockResolvedValue(
1028+
JSON.stringify({
1029+
mcpServers: {
1030+
"test-cmd-uppercase-server": {
1031+
command: "CMD",
1032+
args: ["/c", "echo", "test"],
1033+
},
1034+
},
1035+
}),
1036+
)
1037+
1038+
// Initialize servers (this will trigger connectToServer)
1039+
await mcpHub["initializeGlobalMcpServers"]()
1040+
1041+
// Verify StdioClientTransport was called without double-wrapping
1042+
expect(StdioClientTransport).toHaveBeenCalledWith(
1043+
expect.objectContaining({
1044+
command: "CMD",
1045+
args: ["/c", "echo", "test"],
1046+
}),
1047+
)
1048+
})
8451049
})
8461050
})

0 commit comments

Comments
 (0)