Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@
"author": {
"name": "JacobPEvans"
}
},
{
"name": "process-cleanup",
"source": "./process-cleanup",
"description": "Cleanup orphaned MCP server processes on session exit — workaround for upstream bug #1935",
"version": "1.0.0",
"author": {
"name": "JacobPEvans"
}
}
]
}
9 changes: 9 additions & 0 deletions process-cleanup/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "process-cleanup",
"version": "1.0.0",
"description": "Cleanup orphaned MCP server processes on session exit — workaround for upstream bug #1935",
"author": { "name": "JacobPEvans" },
"license": "Apache-2.0",
"repository": "https://github.com/JacobPEvans/claude-code-plugins",
"keywords": ["hooks", "cleanup", "mcp", "orphan", "process", "stop"]
}
44 changes: 44 additions & 0 deletions process-cleanup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# process-cleanup

Cleans up orphaned MCP server processes when a Claude Code session exits.

## Purpose

Workaround for upstream Claude Code bug [#1935](https://github.com/anthropics/claude-code/issues/1935),
where MCP server child processes are not reliably terminated when a Claude session ends.

This plugin fires on the `Stop` event (triggered by `/exit` or Ctrl+C) and sweeps
system-wide for orphaned processes with `ppid=1` — meaning they were reparented to
launchd because their parent terminal died without cleanup.

## Safety Guarantees

- **Only kills ppid=1 processes** — cannot affect processes with a living parent
- **Cannot affect other Claude sessions** — active sessions have a living parent terminal
- **SIGTERM first, SIGKILL only for survivors** — 2-second grace period
- **Targets node processes by substring match** — matches orphaned `node` processes whose command
line contains `mcp` or `context7`; unrelated node processes with those substrings could be affected

## Targets

| Process | Description |
|---------|-------------|
| `terraform-mcp-server` | Terraform/OpenTofu MCP server |
| `context7-mcp` | Context7 documentation MCP server |
| `node` (with MCP args) | Node-based MCP servers with mcp/context7 in arguments |

## Logs

Cleanup activity is logged to:

```text
~/Library/Logs/claude-process-cleanup/cleanup-YYYY-MM-DD.log
```

## Hook Architecture

This is a **defense-in-depth** second layer. The primary cleanup runs via `zshexit()`
in the parent zsh shell (Layer 1), which catches tab close via SIGHUP. This layer
catches explicit Claude exits (`/exit`, Ctrl+C) when zshexit may not fire.

Both layers are complementary and safe to run together.
16 changes: 16 additions & 0 deletions process-cleanup/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"hooks": {
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-cleanup.sh",
"timeout": 30
}
]
}
]
}
}
98 changes: 98 additions & 0 deletions process-cleanup/scripts/session-cleanup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Claude Code Stop Hook: Cleanup orphaned MCP processes
#
# Fires when a Claude session exits via /exit or Ctrl+C.
# Sweeps for orphaned MCP server processes system-wide (ppid=1 means the
# process was reparented to launchd — its parent terminal died without cleanup).
#
# SAFETY: Only kills processes with ppid=1. Never touches processes that have
# a living parent. Cannot affect active Claude sessions in other terminals.
#
# Targets:
# - terraform-mcp-server (Terraform MCP server)
# - context7-mcp (Context7 MCP server, may run as node)
# - node processes with MCP-related arguments (ppid=1)
#
# Logs to: ~/Library/Logs/claude-process-cleanup/

set -euo pipefail

LOG_DIR="$HOME/Library/Logs/claude-process-cleanup"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/cleanup-$(date +%Y-%m-%d).log"

log_info() {
echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO] $*" >> "$LOG_FILE"
}

log_warn() {
echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] $*" >> "$LOG_FILE"
}

# Find orphaned processes where ppid=1 and command path matches a pattern
find_orphans_by_pattern() {
local pattern=$1
ps -Aeo pid,ppid,command | awk -v pat="$pattern" '$2 == 1 && $3 ~ pat {print $1}'
}

# Find orphaned node processes running MCP servers (ppid=1, args reference mcp/context7)
find_orphan_node_mcp() {
ps -Aeo pid,ppid,command | awk '$2 == 1 && $3 ~ /node/ && ($0 ~ /mcp|context7/) {print $1}'
}

declare -a mcp_patterns=(
"terraform-mcp"
"context7-mcp"
)

declare -A seen_pids=()
declare -a all_pids=()

# Collect orphans by process name pattern (deduplicate via seen_pids)
for pattern in "${mcp_patterns[@]}"; do
while IFS= read -r pid; do
[[ -n "$pid" ]] || continue
[[ -n "${seen_pids[$pid]:-}" ]] && continue
seen_pids[$pid]=1
all_pids+=("$pid")
log_info "Found orphaned ${pattern} (pid=${pid}, ppid=1)"
done < <(find_orphans_by_pattern "$pattern")
done

# Collect orphaned node MCP processes (deduplicate)
while IFS= read -r pid; do
[[ -n "$pid" ]] || continue
[[ -n "${seen_pids[$pid]:-}" ]] && continue
seen_pids[$pid]=1
all_pids+=("$pid")
log_info "Found orphaned node MCP process (pid=${pid}, ppid=1)"
done < <(find_orphan_node_mcp)

[[ ${#all_pids[@]} -eq 0 ]] && exit 0

total_killed=0

# SIGTERM (graceful shutdown)
for pid in "${all_pids[@]}"; do
if kill -TERM "$pid" 2>/dev/null; then
total_killed=$((total_killed + 1))
fi
done

sleep 2

# SIGKILL survivors — re-validate PID identity before escalating
for pid in "${all_pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
# Confirm PID still belongs to a target process (guards against PID reuse)
pid_cmd=$(ps -p "$pid" -o command= 2>/dev/null || true)
if [[ "$pid_cmd" =~ terraform-mcp|context7-mcp|node ]]; then
log_warn "SIGKILL to surviving process (pid=${pid}, cmd=${pid_cmd})"
kill -KILL "$pid" 2>/dev/null || true
fi
fi
done

log_info "Cleanup complete: sent SIGTERM to ${total_killed} orphaned MCP process(es)"

exit 0
Loading