Private/Start-McpProcess.ps1

function ConvertTo-CmdArgument {
    <#
    .SYNOPSIS
        Quotes one argument for the cmd.exe /s /c command line that runs a .cmd/.bat shim forwarding %* to a real executable.
    .DESCRIPTION
        Two parsers see this text. cmd.exe toggles its quote state on every ", expands %VAR% regardless of quotes and, when
        delayed expansion is enabled, expands !VAR! too. The executable the shim launches (node.exe, python.exe, ...) then
        applies the C runtime argv rules: 2n backslashes before a " collapse to n, and "" inside a quoted region is a literal ".
 
        So every argument is wrapped in quotes to keep cmd metacharacters (& | < > ^) inert; an embedded " becomes "" (cmd's
        quote state stays balanced and the CRT yields a literal "); backslashes that precede a " or the end of the argument
        are doubled (CRT rule); and % becomes %%cd:~,% - cmd emits the first % literally, then expands the empty substring
        of the dynamic cd variable to nothing, so a single literal % survives without any %VAR% lookup. ! is not escaped
        here: Resolve-McpLaunchSpec starts cmd.exe with /v:off so delayed expansion cannot fire even on hosts that enable
        it in the registry.
 
        Arguments containing a carriage return, line feed or NUL are rejected: cmd.exe truncates its command line at a
        line feed (silently dropping that argument's tail and every argument after it) and strips carriage returns, so
        this transport cannot carry them and a multiline argument would otherwise be a command-injection vector.
    #>

    param([string]$Value)

    if ($Value -match '[\r\n\x00]') {
        $preview = $Value -replace "`r", '\r' -replace "`n", '\n' -replace "`0", '\0'
        if ($preview.Length -gt 60) { $preview = $preview.Substring(0, 60) + '...' }
        throw (New-McpError -ErrorId 'McpUnsafeCmdArgument' -Category InvalidArgument -TargetObject $Value `
            -Message "Argument '$preview' contains a carriage return, line feed or NUL, which cmd.exe cannot pass to a .cmd/.bat shim: it truncates the command line at a line feed and strips carriage returns. Launch the underlying executable directly instead, for example 'node <path-to-cli.js>'.")
    }

    $sb = [System.Text.StringBuilder]::new('"')
    $pendingBackslashes = 0
    foreach ($ch in ([string]$Value).ToCharArray()) {
        if ($ch -eq '\') { $pendingBackslashes++; continue }
        if ($ch -eq '"') {
            [void]$sb.Append([char]'\', $pendingBackslashes * 2)
            [void]$sb.Append('""')
        }
        else {
            [void]$sb.Append([char]'\', $pendingBackslashes)
            if ($ch -eq '%') { [void]$sb.Append('%%cd:~,%') } else { [void]$sb.Append($ch) }
        }
        $pendingBackslashes = 0
    }
    [void]$sb.Append([char]'\', $pendingBackslashes * 2)
    [void]$sb.Append('"')
    $sb.ToString()
}

function Resolve-McpLaunchSpec {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Command,
        [string[]]$Arguments = @()
    )

    $path = $null
    if (Test-Path -LiteralPath $Command -PathType Leaf) {
        $path = (Resolve-Path -LiteralPath $Command).ProviderPath
    }
    else {
        $apps = @(Get-Command -Name $Command -CommandType Application -ErrorAction SilentlyContinue)
        if ($IsWindows) {
            # pwsh resolves 'npx' to npx.ps1 first; the .cmd shim is what actually runs via CreateProcess
            $preferred = $apps | Where-Object { [IO.Path]::GetExtension($_.Source) -in '.exe', '.com' } | Select-Object -First 1
            if (-not $preferred) { $preferred = $apps | Where-Object { [IO.Path]::GetExtension($_.Source) -in '.cmd', '.bat' } | Select-Object -First 1 }
            if (-not $preferred) { $preferred = $apps | Select-Object -First 1 }
        }
        else {
            $preferred = $apps | Select-Object -First 1
        }

        if ($preferred) {
            $path = $preferred.Source
        }
        else {
            $any = Get-Command -Name $Command -ErrorAction SilentlyContinue | Select-Object -First 1
            if ($any -and $any.CommandType -in 'ExternalScript', 'Application') {
                $path = $any.Source
            }
        }
    }

    if (-not $path) {
        throw (New-McpError -ErrorId 'McpCommandNotFound' -Category ObjectNotFound -TargetObject $Command `
            -Message "Command '$Command' could not be resolved. Ensure it is on PATH or supply a full path.")
    }

    $extension = [IO.Path]::GetExtension($path).ToLowerInvariant()

    if ($extension -eq '.ps1') {
        $pwsh = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
        return @{ FileName = $pwsh; ArgumentList = @('-NoProfile', '-NonInteractive', '-File', $path) + $Arguments }
    }

    if ($IsWindows -and $extension -in '.cmd', '.bat') {
        # cmd.exe runs the shim, which forwards %* to the real executable. /s strips only the outer quotes so the quoted
        # path plus quoted arguments survive; /d skips AutoRun; /e:on guarantees the command extensions that the %cd:~,%
        # escape in ConvertTo-CmdArgument relies on; /v:off forces delayed expansion off so a host with DelayedExpansion=1
        # in the registry cannot expand or swallow ! characters before the shim sees them.
        $parts = @($path) + $Arguments | ForEach-Object { ConvertTo-CmdArgument $_ }
        return @{ FileName = Join-Path $env:SystemRoot 'System32\cmd.exe'; RawArguments = '/d /e:on /v:off /s /c "' + ($parts -join ' ') + '"' }
    }

    @{ FileName = $path; ArgumentList = $Arguments }
}

function Start-McpProcess {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Command,
        [string[]]$Arguments = @(),
        [System.Collections.IDictionary]$Environment,
        [string]$WorkingDirectory
    )

    $launch = Resolve-McpLaunchSpec -Command $Command -Arguments $Arguments

    $psi = [System.Diagnostics.ProcessStartInfo]::new()
    $psi.FileName = $launch.FileName
    if ($launch.RawArguments) {
        $psi.Arguments = $launch.RawArguments
    }
    else {
        foreach ($argument in $launch.ArgumentList) { $psi.ArgumentList.Add([string]$argument) }
    }
    $psi.UseShellExecute = $false
    $psi.CreateNoWindow = $true
    $psi.RedirectStandardInput = $true
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError = $true

    $utf8 = [System.Text.UTF8Encoding]::new($false)
    $psi.StandardInputEncoding = $utf8
    $psi.StandardOutputEncoding = $utf8
    $psi.StandardErrorEncoding = $utf8

    if ($WorkingDirectory) { $psi.WorkingDirectory = $WorkingDirectory }
    if ($Environment) {
        foreach ($key in $Environment.Keys) {
            $psi.Environment[[string]$key] = [string]$Environment[$key]
        }
    }

    $display = if ($launch.RawArguments) { "$($psi.FileName) $($launch.RawArguments)" } else { "$($psi.FileName) $($launch.ArgumentList -join ' ')" }
    Write-Verbose "Starting MCP server process: $display"

    $process = [System.Diagnostics.Process]::Start($psi)
    if (-not $process) {
        throw (New-McpError -ErrorId 'McpProcessStartFailed' -Category ResourceUnavailable -TargetObject $Command `
            -Message "Failed to start MCP server process: $display")
    }
    $process.StandardInput.AutoFlush = $true

    [pscustomobject]@{
        Process    = $process
        StdIn      = $process.StandardInput
        StdOut     = $process.StandardOutput
        StderrTask = $process.StandardError.ReadToEndAsync()
    }
}