Skip to content

Instantly share code, notes, and snippets.

@tamirdresher
Created December 15, 2025 06:53
Show Gist options
  • Select an option

  • Save tamirdresher/b6a108565c8be38a7c5ccd157dfb5fa1 to your computer and use it in GitHub Desktop.

Select an option

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
# 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