Skip to content

Commit 5a4f681

Browse files
hannesrudolphdaniel-lxs
authored andcommitted
fix: address review feedback - prevent double-wrapping and add tests
1 parent 0594dd8 commit 5a4f681

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
@@ -580,11 +580,20 @@ export class McpHub {
580580

581581
if (configInjected.type === "stdio") {
582582
// On Windows, wrap commands with cmd.exe to handle non-exe executables like npx.ps1
583+
// This is necessary for node version managers (fnm, nvm-windows, volta) that implement
584+
// commands as PowerShell scripts rather than executables.
585+
// Note: This adds a small overhead as commands go through an additional shell layer.
583586
const isWindows = process.platform === "win32"
584-
const command = isWindows ? "cmd.exe" : configInjected.command
585-
const args = isWindows
586-
? ["/c", configInjected.command, ...(configInjected.args || [])]
587-
: configInjected.args
587+
588+
// Check if command is already cmd.exe to avoid double-wrapping
589+
const isAlreadyWrapped =
590+
configInjected.command.toLowerCase() === "cmd.exe" || configInjected.command.toLowerCase() === "cmd"
591+
592+
const command = isWindows && !isAlreadyWrapped ? "cmd.exe" : configInjected.command
593+
const args =
594+
isWindows && !isAlreadyWrapped
595+
? ["/c", configInjected.command, ...(configInjected.args || [])]
596+
: configInjected.args
588597

589598
transport = new StdioClientTransport({
590599
command,

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

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

0 commit comments

Comments
 (0)