Created
December 15, 2025 06:53
-
-
Save tamirdresher/53be74dcefe86272bb7af442333b36f4 to your computer and use it in GitHub Desktop.
MCP stdio Proxy for Linux/macOS - Debug Model Context Protocol servers by intercepting and logging all JSON-RPC communication. Cross-platform support for Linux, macOS, and Unix systems
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
| #!/usr/bin/env bash | |
| # MCP stdio Proxy - Bash Version | |
| # | |
| # A transparent proxy for debugging Model Context Protocol (MCP) servers. | |
| # Intercepts and logs all JSON-RPC communication between MCP client and server. | |
| # | |
| # Usage: bash mcp-stdio-proxy.sh <command> [log_file] [args...] | |
| # or: ./mcp-stdio-proxy.sh <command> [log_file] [args...] | |
| # | |
| # Example: ./mcp-stdio-proxy.sh 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 | |
| # - Cross-platform (Linux, macOS, Unix, WSL) | |
| # | |
| # Blog post: https://your-blog.com/debugging-mcp-servers | |
| # Repository: https://github.com/your-repo | |
| set -euo pipefail | |
| # Parse arguments | |
| if [ $# -lt 1 ]; then | |
| echo "Usage: $0 <command> [log_file] [args...]" >&2 | |
| exit 1 | |
| fi | |
| COMMAND="$1" | |
| shift | |
| # Determine log file path | |
| if [ $# -ge 1 ] && [[ ! "$1" =~ ^- ]]; then | |
| LOG_FILE="$1" | |
| shift | |
| else | |
| # Default to scripts folder | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| LOG_FILE="$SCRIPT_DIR/mcp_communication.log" | |
| fi | |
| # Convert to absolute path if relative | |
| if [[ ! "$LOG_FILE" = /* ]]; then | |
| LOG_FILE="$(pwd)/$LOG_FILE" | |
| fi | |
| # Ensure log directory exists | |
| LOG_DIR="$(dirname "$LOG_FILE")" | |
| mkdir -p "$LOG_DIR" | |
| # Get timestamp function | |
| get_timestamp() { | |
| date '+%Y-%m-%d %H:%M:%S' | |
| } | |
| get_timestamp_ms() { | |
| if [[ "$OSTYPE" == "darwin"* ]]; then | |
| # macOS | |
| python3 -c "import datetime; print(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3])" | |
| else | |
| # Linux | |
| date '+%Y-%m-%d %H:%M:%S.%3N' | |
| fi | |
| } | |
| # Log startup | |
| TIMESTAMP=$(get_timestamp) | |
| WORKING_DIR="$(pwd)" | |
| PROXY_PID=$$ | |
| { | |
| echo "[$TIMESTAMP] Starting MCP stdio proxy" | |
| echo "[$TIMESTAMP] Working Directory: $WORKING_DIR" | |
| echo "[$TIMESTAMP] Proxy Script PID: $PROXY_PID" | |
| echo "[$TIMESTAMP] Command to execute: $COMMAND $*" | |
| echo "[$TIMESTAMP]" | |
| } >> "$LOG_FILE" | |
| # Walk up the process tree | |
| { | |
| echo "[$TIMESTAMP] ========== PROCESS TREE ==========" | |
| CURRENT_PID=$PROXY_PID | |
| DEPTH=0 | |
| MAX_DEPTH=10 | |
| while [ $DEPTH -lt $MAX_DEPTH ] && [ $CURRENT_PID -gt 1 ]; do | |
| INDENT=$(printf '%*s' $((DEPTH * 2)) '') | |
| # Get process info (different commands for Linux vs macOS) | |
| if [[ "$OSTYPE" == "darwin"* ]]; then | |
| # macOS | |
| PROC_INFO=$(ps -p $CURRENT_PID -o comm=,args= 2>/dev/null || echo "unknown") | |
| PROC_NAME=$(echo "$PROC_INFO" | awk '{print $1}' | xargs basename) | |
| PROC_CMD=$(echo "$PROC_INFO" | cut -d' ' -f2-) | |
| else | |
| # Linux | |
| PROC_NAME=$(ps -p $CURRENT_PID -o comm= 2>/dev/null || echo "unknown") | |
| PROC_CMD=$(ps -p $CURRENT_PID -o args= 2>/dev/null || echo "unknown") | |
| fi | |
| echo "[$TIMESTAMP] $INDENT[$DEPTH] $PROC_NAME (PID: $CURRENT_PID)" | |
| # Truncate long command lines | |
| if [ ${#PROC_CMD} -gt 300 ]; then | |
| PROC_CMD="${PROC_CMD:0:300}... (truncated)" | |
| fi | |
| echo "[$TIMESTAMP] $INDENT CommandLine: $PROC_CMD" | |
| # Get parent PID | |
| if [[ "$OSTYPE" == "darwin"* ]]; then | |
| PARENT_PID=$(ps -p $CURRENT_PID -o ppid= 2>/dev/null | tr -d ' ' || echo "0") | |
| else | |
| PARENT_PID=$(ps -p $CURRENT_PID -o ppid= 2>/dev/null | tr -d ' ' || echo "0") | |
| fi | |
| if [ -z "$PARENT_PID" ] || [ "$PARENT_PID" -le 1 ]; then | |
| break | |
| fi | |
| CURRENT_PID=$PARENT_PID | |
| DEPTH=$((DEPTH + 1)) | |
| done | |
| echo "[$TIMESTAMP] ========================================" | |
| echo "" | |
| } >> "$LOG_FILE" | |
| # Resolve command path | |
| COMMAND_PATH=$(command -v "$COMMAND" 2>/dev/null || echo "$COMMAND") | |
| TIMESTAMP=$(get_timestamp) | |
| echo "[$TIMESTAMP] Resolved command path: $COMMAND_PATH" >> "$LOG_FILE" | |
| # Log environment variables | |
| { | |
| TIMESTAMP=$(get_timestamp) | |
| echo "[$TIMESTAMP] ========== ENVIRONMENT VARIABLES ==========" | |
| # Sort and display environment variables | |
| while IFS='=' read -r name value; do | |
| # Truncate very long values (like PATH) | |
| if [ ${#value} -gt 200 ]; then | |
| DISPLAY_VALUE="${value:0:200}... (truncated, length: ${#value})" | |
| else | |
| DISPLAY_VALUE="$value" | |
| fi | |
| echo "[$TIMESTAMP] ENV: $name = $DISPLAY_VALUE" | |
| done < <(env | sort) | |
| echo "[$TIMESTAMP] ========================================" | |
| echo "" | |
| } >> "$LOG_FILE" | |
| # Create named pipes for logging | |
| TEMP_DIR=$(mktemp -d) | |
| STDIN_PIPE="$TEMP_DIR/stdin.pipe" | |
| STDOUT_PIPE="$TEMP_DIR/stdout.pipe" | |
| STDERR_PIPE="$TEMP_DIR/stderr.pipe" | |
| mkfifo "$STDIN_PIPE" "$STDOUT_PIPE" "$STDERR_PIPE" | |
| # Cleanup function | |
| cleanup() { | |
| rm -rf "$TEMP_DIR" | |
| # Kill background jobs | |
| jobs -p | xargs -r kill 2>/dev/null || true | |
| } | |
| trap cleanup EXIT INT TERM | |
| # Start the child process with redirected streams | |
| TIMESTAMP=$(get_timestamp) | |
| echo "[$TIMESTAMP] Starting child process: $COMMAND_PATH $*" >> "$LOG_FILE" | |
| # Launch child process with redirected stdio | |
| "$COMMAND_PATH" "$@" \ | |
| < "$STDIN_PIPE" \ | |
| > "$STDOUT_PIPE" \ | |
| 2> "$STDERR_PIPE" & | |
| CHILD_PID=$! | |
| TIMESTAMP=$(get_timestamp) | |
| echo "[$TIMESTAMP] Process started successfully (PID: $CHILD_PID)" >> "$LOG_FILE" | |
| # Background job to forward stdin and log it | |
| { | |
| while IFS= read -r line; do | |
| TIMESTAMP=$(get_timestamp_ms) | |
| echo "[$TIMESTAMP] CLIENT -> SERVER: $line" >> "$LOG_FILE" | |
| echo "$line" | |
| done < /dev/stdin > "$STDIN_PIPE" | |
| } & | |
| STDIN_PID=$! | |
| # Background job to forward stdout and log it | |
| { | |
| while IFS= read -r line; do | |
| TIMESTAMP=$(get_timestamp_ms) | |
| echo "[$TIMESTAMP] SERVER -> CLIENT: $line" >> "$LOG_FILE" | |
| echo "$line" | |
| done < "$STDOUT_PIPE" | |
| } & | |
| STDOUT_PID=$! | |
| # Background job to forward stderr and log it | |
| { | |
| while IFS= read -r line; do | |
| TIMESTAMP=$(get_timestamp_ms) | |
| echo "[$TIMESTAMP] SERVER STDERR: $line" >> "$LOG_FILE" | |
| echo "$line" >&2 | |
| done < "$STDERR_PIPE" | |
| } & | |
| STDERR_PID=$! | |
| # Wait for child process to exit | |
| wait $CHILD_PID | |
| EXIT_CODE=$? | |
| # Give background jobs a moment to finish | |
| sleep 0.5 | |
| # Kill forwarding jobs | |
| kill $STDIN_PID $STDOUT_PID $STDERR_PID 2>/dev/null || true | |
| # Log exit | |
| EXIT_TIME=$(get_timestamp) | |
| { | |
| echo "" | |
| echo "[$EXIT_TIME] Process exited with code: $EXIT_CODE" | |
| echo "[$EXIT_TIME] ========================================" | |
| echo "" | |
| } >> "$LOG_FILE" | |
| exit $EXIT_CODE |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment