Skip to content

Instantly share code, notes, and snippets.

@semikolon
Created August 5, 2025 22:11
Show Gist options
  • Save semikolon/7f6791779e0f8ac07a41fd29a19eb44b to your computer and use it in GitHub Desktop.
Save semikolon/7f6791779e0f8ac07a41fd29a19eb44b to your computer and use it in GitHub Desktop.

Revisions

  1. semikolon created this gist Aug 5, 2025.
    74 changes: 74 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,74 @@
    # Claude Code + Serena MCP Auto-Wrapper

    **Zero-configuration automatic Serena MCP server management for Claude Code**

    Transparently starts exactly one Serena instance per project with unique ports. No per-project setup required!

    ## ✨ Features

    - **Zero Configuration**: Just run `claude` - Serena starts automatically
    - **Per-Project Isolation**: Each project gets its own Serena instance on unique ports (9000+)
    - **Race-Condition Safe**: Multiple terminal tabs won't create duplicate instances
    - **Self-Healing**: Detects and restarts crashed Serena processes
    - **Cross-Platform**: Works on macOS and Linux
    - **Optimized**: Fast health checks, efficient process management

    ## 🚀 Quick Setup

    1. **Save the script** as `claude` somewhere in your PATH (e.g., `~/bin/claude`)
    2. **Make it executable**: `chmod +x ~/bin/claude`
    3. **Ensure ~/bin is in PATH**: Add `export PATH="$HOME/bin:$PATH"` to your shell config
    4. **Configure Claude Code** MCP settings in `~/.claude.json`:
    ```json
    {
    "mcpServers": {
    "serena": {
    "type": "sse",
    "url": "${SERENA_URL}"
    }
    }
    }
    ```

    That's it! Now just run `claude` from any project directory.

    ## 🎯 How It Works

    1. **Auto-detects project root** (git repo or current directory)
    2. **Assigns consistent port** based on project path hash
    3. **Starts Serena if needed** or reuses existing healthy instance
    4. **Sets SERENA_URL** environment variable
    5. **Executes real Claude** with full transparency

    ## 🔧 Cache & Debugging

    - **Cache location**: `~/.cache/serena/<project-hash>/`
    - **Log files**: `~/.cache/serena/<project-hash>/serena.log`
    - **Clean cache**: `rm -rf ~/.cache/serena/`

    ## ⚠️ Critical Insight

    The biggest debugging lesson: **Never health-check SSE endpoints with curl** - they stream forever! This wrapper uses `/dev/tcp` port testing instead, which was the key to solving all "backgrounding" issues.

    ## 📋 Requirements

    - **uvx** for Serena installation
    - **Claude Code** with MCP support
    - **Bash** 4.0+ (standard on macOS/Linux)

    ## 🤝 Contributing

    Found this useful? Star the gist! Issues or improvements? Leave a comment below.

    ## 📚 Development Notes

    This wrapper went through several iterations:
    1. **direnv approach** → Required per-project setup
    2. **Complex process detachment** → Over-engineered solutions
    3. **SSE health check discovery** → The real breakthrough!

    The final solution uses simple, reliable POSIX tools with comprehensive error handling and optimization.

    ---

    **Made with ❤️ for the Claude Code + Serena community**
    227 changes: 227 additions & 0 deletions claude-serena-wrapper.sh
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,227 @@
    #!/usr/bin/env bash
    # Claude Code wrapper with automatic Serena MCP server management
    # Transparently starts exactly one Serena instance per project with unique ports
    #
    # FINAL SOLUTION RATIONALE:
    # ========================
    # PATH wrapper + uvx + nohup/disown + /dev/tcp health check + mkdir locking
    #
    # Why this combination?
    # - PATH wrapper: Zero per-project setup, works with any claude invocation (IDE, CLI, etc.)
    # - uvx: No global installs, automatic caching, version isolation, simple backgrounding
    # - nohup+disown: POSIX standard, reliable process detachment, simpler than script/setsid
    # - /dev/tcp health: Instant port test, avoids SSE streaming hang (the real problem!)
    # - mkdir locking: Portable across macOS/Linux, atomic operation, built-in stale detection
    #
    # DEVELOPMENT EVOLUTION & LESSONS LEARNED:
    # ========================================
    #
    # Original Problem: Manual Serena startup for each project was tedious, needed automation
    # for multi-project workflow with separate terminal tabs.
    #
    # Evolution 1: direnv + .envrc approach
    # - Used .envrc files to auto-start Serena per project
    # - Issues: Required per-project setup, direnv dependency, process management complexity
    #
    # Evolution 2: PATH wrapper approach
    # - Wrapper intercepts all `claude` calls, starts Serena transparently
    # - Breakthrough: Zero per-project configuration needed
    #
    # Evolution 3: Complex process detachment attempts
    # - Tried: script command, setsid, complex uvx alternatives
    # - Issue: Commands would hang, assumed backgrounding problems
    # - Red herring: Spent significant time on process detachment solutions
    #
    # CRITICAL INSIGHT: The problem was ALWAYS the health check!
    # ================================================================
    # SSE endpoints (/sse) stream indefinitely - curl never terminates on them.
    # This caused parent shell to hang waiting for curl, not backgrounding issues.
    #
    # Once we removed curl on SSE endpoints, simple solutions worked perfectly:
    # - uvx backgrounds fine without complex wrappers
    # - nohup+disown works better than script command
    # - /dev/tcp port test replaces hanging curl health check
    #
    # Key Lessons for Future Developers:
    # ==================================
    # 1. NEVER health check SSE endpoints with curl - they stream forever
    # 2. Use /dev/tcp for port connectivity testing instead
    # 3. Simple POSIX solutions (nohup+disown) often beat complex alternatives
    # 4. When debugging hangs, check if you're hitting streaming endpoints
    # 5. mkdir-based locking is portable and reliable across platforms
    #
    # USAGE: Just run `claude` as normal - Serena starts automatically if needed
    # CACHE: ~/.cache/serena/<project-hash>/{port,pid,serena.lock/}
    # PORTS: Auto-assigned from 9000-9999 range, consistent per project

    set -euo pipefail # Fail fast on errors, undefined vars, pipe failures

    # Find the real claude binary once (micro-speed optimization)
    # Rationale: Resolve claude path at start instead of at end to avoid redundant PATH operations
    # We must exclude our own directory to prevent infinite recursion
    original_path="${PATH}"
    filtered_path=$(echo "${PATH}" | tr ':' '\n' | grep -v "^$(dirname "$0")$" | tr '\n' ':' | sed 's/:$//')
    real_claude=$(PATH="${filtered_path}" command -v claude)
    if [[ -z "${real_claude}" ]]; then
    echo "Error: Could not find the real claude binary in PATH" >&2
    exit 1
    fi

    # Detect project root (prefer git, fallback to current directory)
    # Rationale: git root gives us consistent project boundaries, PWD fallback for non-git projects
    project_root=$(git -C "${PWD}" rev-parse --show-toplevel 2>/dev/null || echo "${PWD}")

    # Create cache directory for this project (based on path hash)
    # Rationale: Path hash ensures unique, consistent cache per project, survives directory renames
    project_hash=$(echo -n "${project_root}" | shasum | cut -d' ' -f1)
    cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/serena/${project_hash}"
    mkdir -p "${cache_dir}" # Create now to ensure it exists for all subsequent operations

    # Cache file paths - these track Serena state per project
    port_file="${cache_dir}/port" # Stores the port Serena is running on
    pid_file="${cache_dir}/pid" # Stores the PID of the Serena process
    log_file="${cache_dir}/serena.log" # Serena's stdout/stderr for debugging

    # Function to check if Serena is healthy on given port (safe, non-SSE endpoint)
    # CRITICAL: Do NOT use curl on /sse endpoint - SSE streams never terminate!
    # This was the root cause of all our "backgrounding" issues. The parent shell
    # was hanging waiting for curl to finish, which it never would on SSE endpoints.
    check_serena_health() {
    local port=$1
    # Use /dev/tcp for instant port connectivity test (no HTTP, no hanging)
    # Rationale: timeout 1 for fast failure on shells with long defaults (some use 10s+)
    # /dev/tcp is bash built-in, works without external tools, tests raw TCP connectivity
    timeout 1 bash -c "echo > /dev/tcp/127.0.0.1/${port}" 2>/dev/null
    }

    # Function to find a free port in the 9000-9999 range
    # Rationale: 9000+ range avoids system/privileged ports, gives us 1000 ports for projects
    # Sequential search ensures consistent assignment (same project gets same port if available)
    find_free_port() {
    for ((port=9000; port<=9999; port++)); do
    # lsof checks if any process is listening on this port
    if ! lsof -i ":${port}" >/dev/null 2>&1; then
    echo "$port"
    return
    fi
    done
    # Fallback to random port if 9000-9999 all taken (highly unlikely)
    echo $((RANDOM + 10000))
    }

    # Portable file locking using mkdir (works on both Linux and macOS)
    # Rationale: mkdir is atomic across all filesystems, flock/lockf aren't portable to all macOS
    # We store PID in lock for stale lock detection (process may have crashed)
    lock_dir="${cache_dir}/serena.lock"
    lock_pid_file="${lock_dir}/pid"
    timeout=10 # Max seconds to wait for lock
    sleep_interval=0.2 # Check lock every 200ms

    acquire_lock() {
    local start_time=$(date +%s)

    while :; do
    # mkdir is atomic - either succeeds completely or fails completely
    if mkdir "$lock_dir" 2>/dev/null; then
    # Successfully acquired lock - record our PID for stale detection
    printf '%s\n' "$$" >"$lock_pid_file"
    trap 'release_lock' EXIT INT TERM HUP # Auto-cleanup on exit
    return 0
    fi

    # Lock exists - check if it's stale (holder process died)
    if [[ -f "$lock_pid_file" ]]; then
    local locker_pid=$(cat "$lock_pid_file" 2>/dev/null || echo "")
    # kill -0 tests if process exists without actually sending signal
    if [[ -n "$locker_pid" ]] && ! kill -0 "$locker_pid" 2>/dev/null; then
    echo "Found stale lock held by $locker_pid - removing" >&2
    rm -rf "$lock_dir"
    continue # retry immediately after cleanup
    fi
    fi

    # Check timeout to avoid infinite waiting
    local now=$(date +%s)
    if [[ $((now - start_time)) -ge $timeout ]]; then
    echo "Error: Could not acquire Serena lock after ${timeout}s" >&2
    return 1
    fi
    sleep "$sleep_interval"
    done
    }

    release_lock() {
    # Only release if we own the lock (PID matches ours)
    if [[ -d "$lock_dir" ]] && [[ "$(cat "$lock_pid_file" 2>/dev/null)" == "$$" ]]; then
    rm -rf "$lock_dir"
    rm -f "$pid_file" # Clean up stale PID files (nit-level optimization)
    fi
    }

    # Acquire lock to prevent race conditions (multiple claude invocations simultaneously)
    # Rationale: Without locking, concurrent calls could start multiple Serena instances
    if ! acquire_lock; then
    exit 1
    fi

    # Check if we have a cached port and if Serena is still running
    # Rationale: Reuse existing healthy instances instead of starting duplicates
    if [[ -f "${port_file}" ]]; then
    cached_port=$(cat "${port_file}")
    if check_serena_health "$cached_port"; then
    # Serena is healthy, use existing instance - no startup needed
    export SERENA_URL="http://localhost:${cached_port}/sse"
    else
    # Serena is not healthy (crashed/killed), clean up stale files
    rm -f "${port_file}" "${pid_file}"
    cached_port=""
    fi
    fi

    # Start Serena if we don't have a healthy instance
    if [[ ! -f "${port_file}" ]]; then
    port=$(find_free_port)
    echo "Starting Serena MCP server on port ${port} for project: ${project_root##*/}"

    # Ensure log directory exists (nit-level: survives cache purges)
    mkdir -p "$(dirname "$log_file")"

    # Start Serena using uvx with simple nohup backgrounding
    # Rationale: uvx avoids global installs, nohup+disown is simpler than script/setsid
    # Key: </dev/null prevents uvx from inheriting stdin and potentially hanging
    nohup uvx --from git+https://github.com/oraios/serena serena start-mcp-server \
    --project "${project_root}" \
    --context ide-assistant \
    --transport sse \
    --port "${port}" \
    >"${log_file}" 2>&1 </dev/null &

    serena_pid=$!
    disown # Remove from job control so process survives shell exit
    echo "${serena_pid}" > "${pid_file}" # Cache PID for process management
    echo "${port}" > "${port_file}" # Cache port for reuse

    # Wait for Serena to be ready with safe health check
    # Rationale: Give Serena time to bind to port before Claude tries to connect
    echo "Serena starting on port ${port}..."
    for i in {1..10}; do # Max 5 seconds wait (10 * 0.5s)
    if check_serena_health "${port}"; then
    echo "Serena ready on port ${port}"
    break
    fi
    sleep 0.5
    done

    export SERENA_URL="http://localhost:${port}/sse"
    else
    # Use existing Serena instance
    cached_port=$(cat "${port_file}")
    export SERENA_URL="http://localhost:${cached_port}/sse"
    fi

    # Lock will be automatically released by trap on exit
    # Rationale: Even if exec fails, cleanup happens via trap

    # Execute the real Claude with all arguments (resolved at script start for micro-speed)
    # Rationale: exec replaces current process, so wrapper doesn't consume extra memory/PID
    exec "${real_claude}" "$@"