You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
StdioClientTransport accepts Arguments as a string array, which is the standard .NET signal that arguments are passed to the child process as an argv vector without shell interpretation. On Windows that is not what happens: every non-cmd.exe command is re-wrapped as cmd.exe /c <command> <args…>, so a shell does parse the arguments.
This is well known as a functional bug — #1601 (open) covers the space-in-path failure and quotes the same code; #594 covered the ampersand failure. This issue is about the part neither raises: the wrapping is undocumented on the public API surface, and callers who reason about argument safety from the array-shaped API reach a conclusion that is false on Windows.
I am not asking for a functional fix here — #1601 already proposes good ones, and its option (b) would resolve this too. I am asking for the behaviour to be documented, because until it is fixed, correctness of the caller's security reasoning depends on knowing it.
The code
src/ModelContextProtocol.Core/Client/StdioClientTransport.cs, ConnectAsync (verified against the released ModelContextProtocol.Core1.4.0 by decompilation, and matching main):
if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)&&!string.Equals(Path.GetFileName(command),"cmd.exe",StringComparison.OrdinalIgnoreCase)){arguments=argumentsisnull or []?["/c",command]:["/c",command, ..arguments];command="cmd.exe";}
The SDK then applies its own caret-escaping (EscapeArgumentString, pattern [&^><|]) to each element.
Why the documentation gap has consequences
A caller who builds Arguments as an array and never joins or splits it will reasonably conclude that no shell re-parses their argv, and will therefore treat shell metacharacters in argument values as inert. On Windows that conclusion is wrong, one layer below their own code. Concretely, this changes whether an input allowlist is defense-in-depth or load-bearing — and that difference determines whether it is safe for a future maintainer to relax it.
We hit exactly this. In an MCP governance gateway (darvoza) we had concluded that launching node npx-cli.js … rather than npx.cmd removed the shell from the launch path. It did not — it removed the batch file's own re-parse, while the SDK's cmd.exe /c remained. That conclusion sat in our security notes and code comments until we decompiled the package. No untrusted input reached argv in our case, so nothing was exploitable, but the reasoning was invalid and a comment told the next maintainer it was safe to relax the one guard that was actually carrying the weight.
The escaping helper covers [&^><|]. % (delayed/immediate variable expansion), ", (, ) and ; are not in that set. Whether that matters depends entirely on caller-side guarantees the SDK cannot see — which is the argument for saying so out loud.
What would help
In rough order of value:
XML doc on StdioClientTransportOptions.Arguments and Command stating that on Windows the command is executed via cmd.exe /c, that the SDK applies [&^><|] escaping, and that callers should not assume argv reaches the child unparsed.
A short note in the README / client docs — a sentence where callers are told how to configure a stdio server is worth more than a source comment they will never open.
If StdioClientTransportOptions is failing with space in Command #1601's option (b) lands (skip the wrapping when Command resolves to a real executable), this narrows to PATH-resolved script commands and the docs can say so instead. That would be the better outcome; documenting is the interim.
The existing source comment justifies the wrapping as "usually npx or uvicorn", which reads as a PATH-resolution convenience. That intent is reasonable — it just does not appear to have been evaluated for what it implies about argument handling, and it is unconditional rather than scoped to the case that motivated it.
For anyone verifying this from the package alone: the shipped ModelContextProtocol.Core.xml documents StdioClientTransport.GetWindowsCliSpecialArgumentsRegex with pattern [&^><|]. A Windows CLI argument
escaper, living inside the stdio client transport, only makes sense if that transport builds a Windows
shell command line — which is the behaviour above. The 1.4.0 assembly also contains exactly one cmd.exe
string literal, consistent with the single assignment in the snippet.
To be clear about credit
This behaviour is not a discovery of ours — #594 reported it in 2025 and #1601 is open on it now. What
we are contributing is the argument-handling consequence neither thread raises, and the request to write it
down on the public API surface until the wrapping itself changes.
Summary
StdioClientTransportacceptsArgumentsas a string array, which is the standard .NET signal that arguments are passed to the child process as an argv vector without shell interpretation. On Windows that is not what happens: every non-cmd.execommand is re-wrapped ascmd.exe /c <command> <args…>, so a shell does parse the arguments.This is well known as a functional bug — #1601 (open) covers the space-in-path failure and quotes the same code; #594 covered the ampersand failure. This issue is about the part neither raises: the wrapping is undocumented on the public API surface, and callers who reason about argument safety from the array-shaped API reach a conclusion that is false on Windows.
I am not asking for a functional fix here — #1601 already proposes good ones, and its option (b) would resolve this too. I am asking for the behaviour to be documented, because until it is fixed, correctness of the caller's security reasoning depends on knowing it.
The code
src/ModelContextProtocol.Core/Client/StdioClientTransport.cs,ConnectAsync(verified against the releasedModelContextProtocol.Core1.4.0 by decompilation, and matchingmain):The SDK then applies its own caret-escaping (
EscapeArgumentString, pattern[&^><|]) to each element.Why the documentation gap has consequences
A caller who builds
Argumentsas an array and never joins or splits it will reasonably conclude that no shell re-parses their argv, and will therefore treat shell metacharacters in argument values as inert. On Windows that conclusion is wrong, one layer below their own code. Concretely, this changes whether an input allowlist is defense-in-depth or load-bearing — and that difference determines whether it is safe for a future maintainer to relax it.We hit exactly this. In an MCP governance gateway (darvoza) we had concluded that launching
node npx-cli.js …rather thannpx.cmdremoved the shell from the launch path. It did not — it removed the batch file's own re-parse, while the SDK'scmd.exe /cremained. That conclusion sat in our security notes and code comments until we decompiled the package. No untrusted input reached argv in our case, so nothing was exploitable, but the reasoning was invalid and a comment told the next maintainer it was safe to relax the one guard that was actually carrying the weight.The escaping helper covers
[&^><|].%(delayed/immediate variable expansion),",(,)and;are not in that set. Whether that matters depends entirely on caller-side guarantees the SDK cannot see — which is the argument for saying so out loud.What would help
In rough order of value:
StdioClientTransportOptions.ArgumentsandCommandstating that on Windows the command is executed viacmd.exe /c, that the SDK applies[&^><|]escaping, and that callers should not assume argv reaches the child unparsed.Commandresolves to a real executable), this narrows to PATH-resolved script commands and the docs can say so instead. That would be the better outcome; documenting is the interim.The existing source comment justifies the wrapping as "usually npx or uvicorn", which reads as a PATH-resolution convenience. That intent is reasonable — it just does not appear to have been evaluated for what it implies about argument handling, and it is unconditional rather than scoped to the case that motivated it.
Environment
ModelContextProtocol/ModelContextProtocol.Core1.4.0Related
Command; proposes fixes that would also resolve this.&in an argument.Corroboration without a decompiler
For anyone verifying this from the package alone: the shipped
ModelContextProtocol.Core.xmldocumentsStdioClientTransport.GetWindowsCliSpecialArgumentsRegexwith pattern[&^><|]. A Windows CLI argumentescaper, living inside the stdio client transport, only makes sense if that transport builds a Windows
shell command line — which is the behaviour above. The 1.4.0 assembly also contains exactly one
cmd.exestring literal, consistent with the single assignment in the snippet.
To be clear about credit
This behaviour is not a discovery of ours — #594 reported it in 2025 and #1601 is open on it now. What
we are contributing is the argument-handling consequence neither thread raises, and the request to write it
down on the public API surface until the wrapping itself changes.