fix(mcp): corrected Windows cmd shim spawning

Passed batch commands and arguments separately to cmd.exe /c so cmd.exe no longer strips a wrapper quote into the command token.

Fixes #5696
This commit is contained in:
roboomp
2026-07-16 12:58:05 +00:00
parent 6ae7cdbf97
commit 967befdf42
3 changed files with 22 additions and 62 deletions
+4
View File
@@ -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
@@ -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, string | undefined>): 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,
};
@@ -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);