From a763d948b1484dd83f513b6d56ea972da4873d3b Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:15:58 -0500 Subject: [PATCH 1/2] feat(process-cleanup): add Stop hook plugin for orphaned MCP process cleanup Moves session-cleanup.sh from nix-config to a proper installable plugin. Fires on Claude session exit to sweep for ppid=1 orphaned MCP processes. Workaround for upstream bug #1935. (claude) --- .claude-plugin/marketplace.json | 9 +++ process-cleanup/.claude-plugin/plugin.json | 9 +++ process-cleanup/README.md | 42 ++++++++++ process-cleanup/hooks/hooks.json | 17 +++++ process-cleanup/scripts/session-cleanup.sh | 89 ++++++++++++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 process-cleanup/.claude-plugin/plugin.json create mode 100644 process-cleanup/README.md create mode 100644 process-cleanup/hooks/hooks.json create mode 100755 process-cleanup/scripts/session-cleanup.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 861b16d..19db408 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" + } } ] } diff --git a/process-cleanup/.claude-plugin/plugin.json b/process-cleanup/.claude-plugin/plugin.json new file mode 100644 index 0000000..7668258 --- /dev/null +++ b/process-cleanup/.claude-plugin/plugin.json @@ -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"] +} diff --git a/process-cleanup/README.md b/process-cleanup/README.md new file mode 100644 index 0000000..eb7f5f4 --- /dev/null +++ b/process-cleanup/README.md @@ -0,0 +1,42 @@ +# 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 +- **Never kills arbitrary node processes** — only targets node processes with MCP-related arguments + +## 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: +``` +~/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. diff --git a/process-cleanup/hooks/hooks.json b/process-cleanup/hooks/hooks.json new file mode 100644 index 0000000..0a3046d --- /dev/null +++ b/process-cleanup/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "description": "Sweep for orphaned MCP processes when a Claude session ends", + "hooks": { + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-cleanup.sh", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/process-cleanup/scripts/session-cleanup.sh b/process-cleanup/scripts/session-cleanup.sh new file mode 100755 index 0000000..679c837 --- /dev/null +++ b/process-cleanup/scripts/session-cleanup.sh @@ -0,0 +1,89 @@ +#!/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 -uo 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 all_pids=() + +# Collect orphans by process name pattern +for pattern in "${mcp_patterns[@]}"; do + while IFS= read -r pid; do + [[ -n "$pid" ]] || continue + all_pids+=("$pid") + log_info "Found orphaned ${pattern} (pid=${pid}, ppid=1)" + done < <(find_orphans_by_pattern "$pattern") +done + +# Collect orphaned node MCP processes +while IFS= read -r pid; do + [[ -n "$pid" ]] || continue + 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++)) + fi +done + +sleep 2 + +# SIGKILL survivors +for pid in "${all_pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + log_warn "SIGKILL to surviving process (pid=${pid})" + kill -KILL "$pid" 2>/dev/null || true + fi +done + +log_info "Cleanup complete: sent SIGTERM to ${total_killed} orphaned MCP process(es)" + +exit 0 From c78bbbedc4c1326662297e1088264130d8853b73 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:55:21 -0500 Subject: [PATCH 2/2] fix: address PR review feedback - hooks.json: remove top-level description key (fails cclint schema) - session-cleanup.sh: add -e to set flags, use arithmetic assignment instead of ((n++)) to avoid -e false exit on zero value - session-cleanup.sh: add PID deduplication via associative array - session-cleanup.sh: re-validate process identity before SIGKILL - README.md: weaken overstated node process safety guarantee (claude) --- process-cleanup/README.md | 6 ++++-- process-cleanup/hooks/hooks.json | 1 - process-cleanup/scripts/session-cleanup.sh | 23 +++++++++++++++------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/process-cleanup/README.md b/process-cleanup/README.md index eb7f5f4..31405b1 100644 --- a/process-cleanup/README.md +++ b/process-cleanup/README.md @@ -16,7 +16,8 @@ launchd because their parent terminal died without cleanup. - **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 -- **Never kills arbitrary node processes** — only targets node processes with MCP-related arguments +- **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 @@ -29,7 +30,8 @@ launchd because their parent terminal died without cleanup. ## Logs Cleanup activity is logged to: -``` + +```text ~/Library/Logs/claude-process-cleanup/cleanup-YYYY-MM-DD.log ``` diff --git a/process-cleanup/hooks/hooks.json b/process-cleanup/hooks/hooks.json index 0a3046d..0be3b12 100644 --- a/process-cleanup/hooks/hooks.json +++ b/process-cleanup/hooks/hooks.json @@ -1,5 +1,4 @@ { - "description": "Sweep for orphaned MCP processes when a Claude session ends", "hooks": { "Stop": [ { diff --git a/process-cleanup/scripts/session-cleanup.sh b/process-cleanup/scripts/session-cleanup.sh index 679c837..d4b1aa3 100755 --- a/process-cleanup/scripts/session-cleanup.sh +++ b/process-cleanup/scripts/session-cleanup.sh @@ -15,7 +15,7 @@ # # Logs to: ~/Library/Logs/claude-process-cleanup/ -set -uo pipefail +set -euo pipefail LOG_DIR="$HOME/Library/Logs/claude-process-cleanup" mkdir -p "$LOG_DIR" @@ -45,20 +45,25 @@ declare -a mcp_patterns=( "context7-mcp" ) +declare -A seen_pids=() declare -a all_pids=() -# Collect orphans by process name pattern +# 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 +# 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) @@ -70,17 +75,21 @@ total_killed=0 # SIGTERM (graceful shutdown) for pid in "${all_pids[@]}"; do if kill -TERM "$pid" 2>/dev/null; then - ((total_killed++)) + total_killed=$((total_killed + 1)) fi done sleep 2 -# SIGKILL survivors +# SIGKILL survivors — re-validate PID identity before escalating for pid in "${all_pids[@]}"; do if kill -0 "$pid" 2>/dev/null; then - log_warn "SIGKILL to surviving process (pid=${pid})" - kill -KILL "$pid" 2>/dev/null || true + # 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