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.
Claude Code + Serena MCP Auto-Wrapper - Zero-config per-project Serena instances

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:
    {
      "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

#!/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}" "$@"
@reedom
Copy link

reedom commented Aug 14, 2025

Thanks for this super handy script — it’s been a huge help! 🙏

By the way, on my Mac setup, even when Claude Code starts up, it doesn’t seem to automatically connect to serena. I’ve had to manually run /mcp - Reconnect each time.

I think a tweak like this might solve the issue:

diff -c with-serena.sh with-serena.sh.new
*** with-serena.sh	Thu Aug 14 10:36:34 2025
--- with-serena.sh.new	Thu Aug 14 10:36:01 2025
***************
*** 91,97 ****
      # 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
--- 91,101 ----
      # 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
!     if [[ -e /dev/tcp/127.0.0.1/${port} ]]; then
!         timeout 1 bash -c "echo > /dev/tcp/127.0.0.1/${port}" 2>/dev/null || return 1
!     else
!         nc -z -w 1 127.0.0.1 "${port}" 2>/dev/null || return 1
!     fi
  }
  
  # Function to find a free port in the 9000-9999 range

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment