Skip to content

Instantly share code, notes, and snippets.

@semikolon
Created August 5, 2025 22:11
Show Gist options
  • Select an option

  • Save semikolon/7f6791779e0f8ac07a41fd29a19eb44b to your computer and use it in GitHub Desktop.

Select an option

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

@DJanocha
Copy link

DJanocha commented Dec 4, 2025

Fix: Multi-terminal support - release lock early

Great script! I've been using it successfully but hit an issue with multiple terminals in the same project. The second claude invocation would fail with "Could not acquire Serena lock
after 10s".

Root Cause

The lock is held for the entire Claude session (via trap on exit), but it only needs to protect Serena startup, not the whole session.

Patch (3 changes)

# 1. Health check: Add nc fallback for environments without /dev/tcp
check_serena_health() {
    local port=$1
-    timeout 1 bash -c "echo > /dev/tcp/127.0.0.1/${port}" 2>/dev/null
+    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
}

# 2. Don't delete pid_file in release_lock - it tracks Serena PID, not lock owner
release_lock() {
    if [[ -d "$lock_dir" ]] && [[ "$(cat "$lock_pid_file" 2>/dev/null)" == "$$" ]]; then
        rm -rf "$lock_dir"
-        rm -f "$pid_file"
+        # Note: Don't remove pid_file here - it tracks Serena PID, not lock owner
    fi
}

# 3. Release lock early (before exec, after Serena is ready)
# Add this before the final exec:
+release_lock
exec "${real_claude}" "$@"

Result

- Terminal #1: Starts Serena, releases lock, runs Claude
- Terminal #2: Acquires lock (succeeds!), detects existing Serena, releases lock, runs Claude
- Both share the same Serena instance ✅

Thanks for the excellent wrapper!

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