-
Notifications
You must be signed in to change notification settings - Fork 3
Description
Description
On Windows, certain native executables (notably Go toolchain commands like go vet, go build, go test) hang indefinitely when executed via invoke_expression. The command blocks until the user manually presses Enter in the PowerShell console window.
Environment
- OS: Windows 10/11
- PowerShell.MCP version: 1.7.0
- PowerShell: 7.5.4
- MCP Client: Claude Code
Steps to Reproduce
- Use
invoke_expressionto run a Go toolchain command:go vet ./pkg/... - The command hangs indefinitely. The console shows
Status: Busyand never completes. - Manually pressing Enter in the PowerShell console window unblocks it immediately and the command completes.
Workaround
Piping empty stdin via Get-Content NUL | resolves the issue:
Get-Content NUL | go vet ./pkg/... 2>&1This completes in under 1 second, confirming the root cause is stdin-related.
Root Cause Analysis
The issue is in MCPPollingEngine.ps1, specifically in Invoke-CommandWithAllStreams (line 71–85). Commands are executed via Invoke-Expression, which causes child processes (native executables) to inherit the console's stdin handle.
The execution chain:
PwshLauncherWindows.LaunchPwsh()creates the PowerShell console withCREATE_NEW_CONSOLEflag (PowerShellProcessManager.cs:265), giving it a real console input buffer.Import-Module PSReadLineis loaded in the console (PowerShellProcessManager.cs:262), which puts the console in raw input mode.- When the polling engine timer fires (
MCPPollingEngine.ps1), it callsInvoke-CommandWithAllStreams→Invoke-Expression $Command. - Native executables spawned by
Invoke-Expressioninherit the console's stdin handle. - Some native runtimes (e.g. Go) detect that stdin is a console/terminal and attempt to read from it (or check its state). Since the console input buffer is empty (no user is typing), the read blocks indefinitely.
- When the user presses Enter, data enters the console input buffer, satisfying the pending read, and the command unblocks.
The Get-Content NUL | workaround works because PowerShell replaces the native command's stdin with a pipe (which immediately returns EOF) instead of the console handle. The Go runtime detects piped input rather than a terminal and does not attempt to read.
Proposed Fix
Temporarily redirect the process-level stdin handle to NUL before executing MCP commands, and restore it afterwards. This can be done via Win32 SetStdHandle(STD_INPUT_HANDLE, ...) in MCPPollingEngine.ps1:
# Add P/Invoke for stdin redirection (one-time, Windows only)
if ($IsWindows -and -not ('MCP.StdinRedirector' -as [type])) {
Add-Type -Name 'StdinRedirector' -Namespace 'MCP' -MemberDefinition @'
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GetStdHandle(int nStdHandle);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetStdHandle(int nStdHandle, IntPtr hHandle);
private const int STD_INPUT_HANDLE = -10;
private static IntPtr _savedHandle;
private static System.IO.FileStream _nulStream;
public static void RedirectToNul() {
_savedHandle = GetStdHandle(STD_INPUT_HANDLE);
_nulStream = System.IO.File.OpenRead("\\\\.\\NUL");
SetStdHandle(STD_INPUT_HANDLE, _nulStream.SafeFileHandle.DangerousGetHandle());
}
public static void Restore() {
if (_savedHandle != IntPtr.Zero) {
SetStdHandle(STD_INPUT_HANDLE, _savedHandle);
_savedHandle = IntPtr.Zero;
}
if (_nulStream != null) {
_nulStream.Dispose();
_nulStream = null;
}
}
'@
}Then in Invoke-CommandWithAllStreams:
function Invoke-CommandWithAllStreams {
param([string]$Command)
...
if ($IsWindows) { [MCP.StdinRedirector]::RedirectToNul() }
try {
$redirectedOutput = Invoke-Expression $Command ...
}
...
finally {
if ($IsWindows) { [MCP.StdinRedirector]::Restore() }
}
}Why this is safe
- The timer callback and PSReadLine both run on the main PowerShell thread — there is no concurrency race. PSReadLine is not reading keys while the timer handler is executing.
- .NET's
Consoleclass caches the original console input handle on first use.SetStdHandlechanges the process-level handle (inherited by child processes), but does not affect .NET's cached handle. SoConsole.ReadKey()(used by PSReadLine) continues to work correctly after restore. - This only affects Windows. On macOS/Linux, PSReadLine is explicitly removed (
Remove-Module PSReadLine), so the issue does not occur.
Alternative (more comprehensive)
For the timeout_seconds=0 case (interactive commands where stdin is intentionally needed), a more precise fix would pass timeoutSeconds through CommandSlot to the polling engine, and only redirect stdin when timeoutSeconds > 0. This requires changes to both C# (McpServerHost.CommandSlot) and PowerShell (MCPPollingEngine.ps1).