Created
December 15, 2025 06:53
-
-
Save tamirdresher/b6a108565c8be38a7c5ccd157dfb5fa1 to your computer and use it in GitHub Desktop.
MCP stdio Proxy for Windows - Debug Model Context Protocol servers by intercepting and logging all JSON-RPC communication. Includes process tree analysis, environment dumps
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # MCP stdio Proxy - PowerShell Version | |
| # | |
| # A transparent proxy for debugging Model Context Protocol (MCP) servers. | |
| # Intercepts and logs all JSON-RPC communication between MCP client and server. | |
| # | |
| # Usage: powershell.exe -ExecutionPolicy Bypass -File mcp-stdio-proxy.ps1 <command> [log_file] [args...] | |
| # | |
| # Example: powershell.exe -ExecutionPolicy Bypass -File mcp-stdio-proxy.ps1 npx logs/mcp.log @playwright/mcp@latest | |
| # | |
| # Features: | |
| # - Full JSON-RPC message logging (client ↔ server) | |
| # - Process tree analysis | |
| # - Environment variable dumps | |
| # - Transparent stdio forwarding | |
| # - Exit code preservation | |
| # | |
| # Blog post: https://your-blog.com/debugging-mcp-servers | |
| # Repository: https://github.com/your-repo | |
| param( | |
| [Parameter(Mandatory = $true, Position = 0)] | |
| [string]$Command, | |
| [Parameter(Mandatory = $false, Position = 1)] | |
| [string]$LogFile, | |
| [Parameter(ValueFromRemainingArguments = $true)] | |
| [string[]]$Arguments | |
| ) | |
| # Determine log file path | |
| if ([string]::IsNullOrWhiteSpace($LogFile)) { | |
| # Default to scripts folder | |
| $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path | |
| $LogFile = Join-Path $scriptDir "mcp_communication.log" | |
| } | |
| # Convert to absolute path if relative | |
| if (-not [System.IO.Path]::IsPathRooted($LogFile)) { | |
| $LogFile = Join-Path (Get-Location) $LogFile | |
| } | |
| $logFile = $LogFile | |
| # Ensure log directory exists | |
| $logDir = Split-Path $logFile -Parent | |
| if (-not (Test-Path $logDir)) { | |
| New-Item -ItemType Directory -Path $logDir -Force | Out-Null | |
| } | |
| # Log startup with full process tree detection | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| $workingDir = Get-Location | |
| "[$timestamp] Starting MCP stdio proxy" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$timestamp] Working Directory: $workingDir" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$timestamp] Proxy Script PID: $PID" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$timestamp] Command to execute: $Command $($Arguments -join ' ')" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$timestamp]" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| # Walk up the process tree to find the full chain | |
| "[$timestamp] ========== PROCESS TREE ==========" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| try { | |
| $currentProcess = Get-Process -Id $PID | |
| $depth = 0 | |
| $maxDepth = 10 # Prevent infinite loops | |
| while ($currentProcess -and $depth -lt $maxDepth) { | |
| $indent = " " * $depth | |
| $processName = $currentProcess.ProcessName | |
| $processId = $currentProcess.Id | |
| $processPath = try { $currentProcess.Path } catch { "N/A" } | |
| "[$timestamp] $indent[$depth] $processName (PID: $processId)" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$timestamp] $indent Path: $processPath" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| # Try to get command line (may fail for some processes) | |
| try { | |
| $cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $processId").CommandLine | |
| if ($cmdLine) { | |
| # Truncate very long command lines | |
| if ($cmdLine.Length -gt 300) { | |
| $cmdLine = $cmdLine.Substring(0, 300) + "... (truncated)" | |
| } | |
| "[$timestamp] $indent CommandLine: $cmdLine" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| } | |
| } | |
| catch { | |
| # Skip if we can't get command line | |
| } | |
| # Move to parent | |
| $parentId = (Get-CimInstance Win32_Process -Filter "ProcessId = $processId").ParentProcessId | |
| if ($parentId -and $parentId -ne 0) { | |
| try { | |
| $currentProcess = Get-Process -Id $parentId -ErrorAction Stop | |
| $depth++ | |
| } | |
| catch { | |
| "[$timestamp] $indent (Parent PID $parentId no longer exists)" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| break | |
| } | |
| } | |
| else { | |
| break | |
| } | |
| } | |
| } | |
| catch { | |
| "[$timestamp] ERROR: Failed to trace process tree: $_" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| } | |
| "[$timestamp] ========================================`n" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| try { | |
| # Create process start info | |
| $psi = New-Object System.Diagnostics.ProcessStartInfo | |
| $psi.UseShellExecute = $false | |
| # Check if command exists and get its path | |
| # For npx specifically, prefer .cmd over .ps1 for better stdio compatibility | |
| $cmdInfo = $null | |
| if ($Command -eq 'npx') { | |
| # Try to find npx.cmd first | |
| $npxCmd = Get-Command 'npx.cmd' -ErrorAction SilentlyContinue | |
| if ($npxCmd) { | |
| $cmdInfo = $npxCmd | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] Found npx.cmd, using it for better stdio compatibility" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| } | |
| } | |
| # If not npx or npx.cmd not found, use regular Get-Command | |
| if ($null -eq $cmdInfo) { | |
| $cmdInfo = Get-Command $Command -ErrorAction SilentlyContinue | |
| } | |
| # Build the full command line with arguments | |
| $fullArgs = @() | |
| if ($cmdInfo) { | |
| $commandPath = $cmdInfo.Source | |
| $ext = [System.IO.Path]::GetExtension($commandPath).ToLower() | |
| # For .cmd/.bat files, use cmd.exe wrapper | |
| if ($ext -eq '.cmd' -or $ext -eq '.bat') { | |
| $psi.FileName = "cmd.exe" | |
| $fullArgs += "/c" | |
| $fullArgs += "`"$commandPath`"" | |
| } | |
| # For .ps1 files, try .cmd alternative first, otherwise use powershell.exe | |
| elseif ($ext -eq '.ps1') { | |
| # Check if there's a .cmd version | |
| $cmdPath = $commandPath -replace '\.ps1$', '.cmd' | |
| if (Test-Path $cmdPath) { | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] Found .cmd alternative: $cmdPath" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| $psi.FileName = "cmd.exe" | |
| $fullArgs += "/c" | |
| $fullArgs += "`"$cmdPath`"" | |
| } | |
| else { | |
| $psi.FileName = "powershell.exe" | |
| $fullArgs += "-NoProfile" | |
| $fullArgs += "-ExecutionPolicy" | |
| $fullArgs += "Bypass" | |
| $fullArgs += "-File" | |
| $fullArgs += "`"$commandPath`"" | |
| } | |
| } | |
| else { | |
| $psi.FileName = $commandPath | |
| } | |
| } | |
| else { | |
| # Command not found, use as-is and hope it's in PATH | |
| $psi.FileName = $Command | |
| } | |
| # Add user arguments | |
| if ($null -ne $Arguments -and $Arguments.Length -gt 0) { | |
| foreach ($arg in $Arguments) { | |
| $fullArgs += $arg | |
| } | |
| } | |
| # Set arguments as a single string (alternative to ArgumentList) | |
| if ($fullArgs.Count -gt 0) { | |
| $psi.Arguments = $fullArgs -join ' ' | |
| } | |
| $psi.RedirectStandardInput = $true | |
| $psi.RedirectStandardOutput = $true | |
| $psi.RedirectStandardError = $true | |
| $psi.CreateNoWindow = $true | |
| $psi.WorkingDirectory = $workingDir | |
| # CRITICAL: Copy all environment variables including PATH | |
| $envVars = [System.Environment]::GetEnvironmentVariables() | |
| if ($null -ne $envVars) { | |
| foreach ($key in $envVars.Keys) { | |
| try { | |
| $value = [System.Environment]::GetEnvironmentVariable($key) | |
| if ($null -ne $value -and $null -ne $key) { | |
| $psi.EnvironmentVariables[$key] = $value | |
| } | |
| } | |
| catch { | |
| # Skip problematic environment variables | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] WARNING: Failed to copy environment variable '$key': $_" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| } | |
| } | |
| } | |
| # Log all environment variables for debugging | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] ========== ENVIRONMENT VARIABLES ==========" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| $sortedEnvVars = $psi.EnvironmentVariables.Keys | Sort-Object | |
| foreach ($key in $sortedEnvVars) { | |
| $value = $psi.EnvironmentVariables[$key] | |
| # Truncate very long values (like PATH) for readability | |
| if ($value.Length -gt 200) { | |
| $displayValue = $value.Substring(0, 200) + "... (truncated, length: $($value.Length))" | |
| } else { | |
| $displayValue = $value | |
| } | |
| "[$timestamp] ENV: $key = $displayValue" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| } | |
| "[$timestamp] ========================================`n" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| # Log the resolved command | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] Resolved FileName: $($psi.FileName)" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$timestamp] Full Args Array ($($fullArgs.Count) items): $($fullArgs -join ' | ')" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$timestamp] Arguments String: '$($psi.Arguments)'" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| # Start the process | |
| $process = [System.Diagnostics.Process]::Start($psi) | |
| if ($null -eq $process) { | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] ERROR: Failed to start process" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| exit 1 | |
| } | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] Process started successfully (PID: $($process.Id))" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| # Create runspaces for concurrent forwarding | |
| $runspaces = @() | |
| # Runspace 1: Forward stdin from console to child process | |
| $stdinRunspace = [powershell]::Create() | |
| [void]$stdinRunspace.AddScript({ | |
| param($process, $logFile) | |
| try { | |
| $stdin = $process.StandardInput | |
| $consoleIn = [Console]::In | |
| while ($true) { | |
| $line = $consoleIn.ReadLine() | |
| if ($null -eq $line) { break } | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" | |
| "[$timestamp] CLIENT -> SERVER: $line" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| $stdin.WriteLine($line) | |
| $stdin.Flush() | |
| } | |
| } | |
| catch { | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] ERROR in stdin forwarding: $_" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| } | |
| }).AddArgument($process).AddArgument($logFile) | |
| $runspaces += @{ | |
| PowerShell = $stdinRunspace | |
| Handle = $stdinRunspace.BeginInvoke() | |
| } | |
| # Runspace 2: Forward stdout from child process to console | |
| $stdoutRunspace = [powershell]::Create() | |
| [void]$stdoutRunspace.AddScript({ | |
| param($process, $logFile) | |
| try { | |
| $stdout = $process.StandardOutput | |
| $consoleOut = [Console]::Out | |
| while ($true) { | |
| $line = $stdout.ReadLine() | |
| if ($null -eq $line) { break } | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" | |
| "[$timestamp] SERVER -> CLIENT: $line" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| $consoleOut.WriteLine($line) | |
| $consoleOut.Flush() | |
| } | |
| } | |
| catch { | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] ERROR in stdout forwarding: $_" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| } | |
| }).AddArgument($process).AddArgument($logFile) | |
| $runspaces += @{ | |
| PowerShell = $stdoutRunspace | |
| Handle = $stdoutRunspace.BeginInvoke() | |
| } | |
| # Runspace 3: Forward stderr from child process to console error | |
| $stderrRunspace = [powershell]::Create() | |
| [void]$stderrRunspace.AddScript({ | |
| param($process, $logFile) | |
| try { | |
| $stderr = $process.StandardError | |
| $consoleErr = [Console]::Error | |
| while ($true) { | |
| $line = $stderr.ReadLine() | |
| if ($null -eq $line) { break } | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" | |
| "[$timestamp] SERVER STDERR: $line" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| $consoleErr.WriteLine($line) | |
| $consoleErr.Flush() | |
| } | |
| } | |
| catch { | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] ERROR in stderr forwarding: $_" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| } | |
| }).AddArgument($process).AddArgument($logFile) | |
| $runspaces += @{ | |
| PowerShell = $stderrRunspace | |
| Handle = $stderrRunspace.BeginInvoke() | |
| } | |
| # Wait for the process to exit | |
| $process.WaitForExit() | |
| # Give runspaces a moment to finish | |
| Start-Sleep -Milliseconds 500 | |
| # Clean up runspaces | |
| foreach ($rs in $runspaces) { | |
| try { | |
| $rs.PowerShell.EndInvoke($rs.Handle) | |
| $rs.PowerShell.Dispose() | |
| } | |
| catch { | |
| # Ignore cleanup errors | |
| } | |
| } | |
| # Log exit | |
| $exitTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "`n[$exitTime] Process exited with code: $($process.ExitCode)" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$exitTime] ========================================`n" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| exit $process.ExitCode | |
| } | |
| catch { | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "[$timestamp] FATAL ERROR: $_" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$timestamp] Error Details: $($_.Exception.Message)" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| "[$timestamp] Stack Trace: $($_.ScriptStackTrace)" | Out-File -FilePath $logFile -Append -Encoding UTF8 | |
| exit 1 | |
| } | |
| finally { | |
| if ($null -ne $process) { | |
| $process.Dispose() | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment