diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0e753ced7..a72f8bb47 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Windows stdio MCP servers launched through `.cmd` shims failing with `Transport closed`; `cmd.exe /c` now receives the command and arguments as separate spawn arguments instead of a `/s /c` string with a quoted command token ([#5696](https://github.com/can1357/oh-my-pi/issues/5696)). + ## [17.0.1] - 2026-07-16 ### Changed diff --git a/packages/coding-agent/src/mcp/transports/stdio.ts b/packages/coding-agent/src/mcp/transports/stdio.ts index dbed6ec4d..e2417a5ce 100644 --- a/packages/coding-agent/src/mcp/transports/stdio.ts +++ b/packages/coding-agent/src/mcp/transports/stdio.ts @@ -203,23 +203,6 @@ async function resolveWindowsNpmShimCommand( }; } -function quoteCmdArg(value: string): string { - if (value.length === 0) return '""'; - let result = '"'; - for (const char of value) { - if (char === '"') { - result += '^"'; - } else if (char === "^") { - result += "^^"; - } else if (char === "%") { - result += "^%"; - } else { - result += char; - } - } - return `${result}"`; -} - function isWindowsBatchCommand(command: string): boolean { return WINDOWS_BATCH_EXTENSIONS.has(path.extname(command).toLowerCase()); } @@ -229,12 +212,6 @@ function resolveComSpec(env: Record): string { return comspec && comspec.length > 0 ? comspec : "cmd.exe"; } -/** `cmd /s /c` strips one outer quote pair; keep inner argv quotes intact. */ -function buildCmdExeCommand(command: string, args: readonly string[]): string { - const quotedCommand = [command, ...args].map(quoteCmdArg).join(" "); - return `"${quotedCommand}"`; -} - /** * Resolve the subprocess argv used to launch an MCP stdio server. * @@ -271,7 +248,7 @@ export async function resolveStdioSpawnCommand( if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide, detached }; return { - cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)], + cmd: [resolveComSpec(options.env), "/d", "/c", resolvedCommand, ...args], windowsHide, detached, }; diff --git a/packages/coding-agent/test/mcp-stdio-transport.test.ts b/packages/coding-agent/test/mcp-stdio-transport.test.ts index 27e3b259c..6ebe79b33 100644 --- a/packages/coding-agent/test/mcp-stdio-transport.test.ts +++ b/packages/coding-agent/test/mcp-stdio-transport.test.ts @@ -25,13 +25,7 @@ describe("resolveStdioSpawnCommand", () => { }, ); - expect(result.cmd).toEqual([ - "C:\\Windows\\System32\\cmd.exe", - "/d", - "/s", - "/c", - `""${shim}" "serve" "--mcp""`, - ]); + expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/c", shim, "serve", "--mcp"]); expect(result.windowsHide).toBe(true); expect(result.detached).toBe(false); } finally { @@ -61,7 +55,7 @@ describe("resolveStdioSpawnCommand", () => { }, ); - expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/s", "/c", `""${localShim}" "serve""`]); + expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/c", localShim, "serve"]); expect(result.windowsHide).toBe(true); expect(result.detached).toBe(false); } finally { @@ -112,7 +106,7 @@ describe("resolveStdioSpawnCommand", () => { }, ); - expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/s", "/c", `""${shim}" "-y" "mcp-gdb""`]); + expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/c", shim, "-y", "mcp-gdb"]); expect(result.windowsHide).toBe(false); expect(result.detached).toBe(false); } finally { @@ -206,7 +200,7 @@ describe("resolveStdioSpawnCommand", () => { }, ); - expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/s", "/c", `""${shim}" "serve""`]); + expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/c", shim, "serve"]); expect(result.windowsHide).toBe(true); expect(result.detached).toBe(false); } finally { @@ -214,7 +208,7 @@ describe("resolveStdioSpawnCommand", () => { } }); - it("escapes percent-delimited args before routing .cmd shims through cmd.exe", async () => { + it("preserves percent-delimited args when routing .cmd shims through cmd.exe", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-percent-")); try { const shim = path.join(tempDir, "codegraph.cmd"); @@ -236,9 +230,11 @@ describe("resolveStdioSpawnCommand", () => { expect(result.cmd).toEqual([ "C:\\Windows\\System32\\cmd.exe", "/d", - "/s", "/c", - `""${shim}" "serve" "--header" "Authorization=^%TOKEN^%""`, + shim, + "serve", + "--header", + "Authorization=%TOKEN%", ]); expect(result.windowsHide).toBe(true); expect(result.detached).toBe(false); @@ -247,7 +243,7 @@ describe("resolveStdioSpawnCommand", () => { } }); - it("escapes quoted JSON args before routing .cmd shims through cmd.exe", async () => { + it("preserves quoted JSON args when routing .cmd shims through cmd.exe", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-quotes-")); try { const shim = path.join(tempDir, "codegraph.cmd"); @@ -266,13 +262,7 @@ describe("resolveStdioSpawnCommand", () => { }, ); - expect(result.cmd).toEqual([ - "C:\\Windows\\System32\\cmd.exe", - "/d", - "/s", - "/c", - `""${shim}" "--config" "{^"a^":^"b&c|d^"}""`, - ]); + expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/c", shim, "--config", '{"a":"b&c|d"}']); expect(result.windowsHide).toBe(true); expect(result.detached).toBe(false); } finally { @@ -307,13 +297,7 @@ describe("resolveStdioSpawnCommand", () => { }, ); - expect(result.cmd).toEqual([ - "C:\\Windows\\System32\\cmd.exe", - "/d", - "/s", - "/c", - `""${shim}" "serve" "--mcp""`, - ]); + expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/c", shim, "serve", "--mcp"]); expect(result.windowsHide).toBe(true); expect(result.detached).toBe(false); } finally { @@ -321,7 +305,7 @@ describe("resolveStdioSpawnCommand", () => { } }); - it("wraps explicit Windows .cmd commands with cmd.exe while preserving quoted argv", async () => { + it("passes explicit Windows .cmd commands and argv separately to cmd.exe", async () => { const result = await resolveStdioSpawnCommand( { type: "stdio", command: "codegraph.cmd", args: ["serve", "--mcp"] }, { @@ -335,13 +319,7 @@ describe("resolveStdioSpawnCommand", () => { }, ); - expect(result.cmd).toEqual([ - "C:\\Windows\\System32\\cmd.exe", - "/d", - "/s", - "/c", - `""codegraph.cmd" "serve" "--mcp""`, - ]); + expect(result.cmd).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/c", "codegraph.cmd", "serve", "--mcp"]); expect(result.windowsHide).toBe(true); expect(result.detached).toBe(false); }); @@ -371,9 +349,10 @@ describe("resolveStdioSpawnCommand", () => { expect(result.cmd).toEqual([ "C:\\Windows\\System32\\cmd.exe", "/d", - "/s", "/c", - `""npx" "-y" "cloakbrowser-mcp@latest""`, + "npx", + "-y", + "cloakbrowser-mcp@latest", ]); expect(result.windowsHide).toBe(true); expect(result.detached).toBe(false);