fix: make Bash MCP host registration explicit

This commit is contained in:
jhuang-tw
2026-08-18 00:11:19 -07:00
parent c21cb7b5f1
commit 919908885a
+125 -134
View File
@@ -5,12 +5,12 @@
# Supports the same capability names and the same high-level modes:
# - dependency expansion
# - package / release / pipx / npm installation
# - MCP registration hints / config writing
# - optional, explicit MCP host registration
# - optional service start with --start-services
# - refresh tool index unless --skip-refresh
#
# Usage:
# bash skills/scripts/bootstrap-reverse.sh <capability1> [capability2] ... [--start-services] [--skip-refresh]
# bash skills/scripts/bootstrap-reverse.sh <capability1> [capability2] ... [--start-services] [--skip-refresh] [--mcp-host=none|claude|codex|both]
# bash skills/scripts/bootstrap-reverse.sh --list
set -euo pipefail
@@ -26,7 +26,9 @@ if [[ -z "$TOOLS_ROOT" || "$TOOLS_ROOT" == "/" || "$TOOLS_ROOT" == "$HOME" ]]; t
echo "Unsafe REVERSE_SKILL_TOOLS_DIR: $TOOLS_ROOT" >&2
exit 2
fi
MCP_CONFIG_PATH="${CLAUDE_MCP_CONFIG:-$HOME/.claude/mcp.json}"
CLAUDE_MCP_CONFIG_PATH="${CLAUDE_MCP_CONFIG:-$HOME/.claude/mcp.json}"
CODEX_MCP_CONFIG_PATH="${CODEX_CONFIG_PATH:-$HOME/.codex/config.toml}"
MCP_HOST_TARGET="none"
MANIFEST_PATH="$SCRIPT_DIR/bootstrap-manifest.json"
UNAME_S="$(uname -s 2>/dev/null || echo unknown)"
@@ -42,6 +44,7 @@ LIST_ONLY=false
MANUAL_REQUIRED=false
FAILED=false
LAST_CAPABILITY_MANUAL=false
LAST_CAPABILITY_REGISTRATION_REQUIRED=false
CAPABILITIES=()
for arg in "$@"; do
@@ -50,6 +53,10 @@ for arg in "$@"; do
--skip-refresh) SKIP_REFRESH=true ;;
--list|-l) LIST_ONLY=true ;;
--help|-h) CAPABILITIES+=("__help__") ;;
--mcp-host=none|--mcp-host=claude|--mcp-host=codex|--mcp-host=both)
MCP_HOST_TARGET="${arg#--mcp-host=}"
;;
--mcp-host=*) echo "Invalid MCP host target: ${arg#--mcp-host=}" >&2; exit 2 ;;
-*) echo "Unknown option: $arg" >&2; exit 2 ;;
*) CAPABILITIES+=("$arg") ;;
esac
@@ -126,8 +133,7 @@ make_temp_file() {
local suffix="${1:-download}"
local tmp_dir
tmp_dir="$(mktemp -d /tmp/reverse-bootstrap-XXXXXX)"
printf '%s/%s
' "$tmp_dir" "$suffix"
printf '%s/%s\n' "$tmp_dir" "$suffix"
}
sudo_cmd() {
@@ -170,7 +176,7 @@ platform_doc() {
print_usage() {
cat <<'EOF'
Usage:
bash skills/scripts/bootstrap-reverse.sh <capability1> [capability2] ... [--start-services] [--skip-refresh]
bash skills/scripts/bootstrap-reverse.sh <capability1> [capability2] ... [--start-services] [--skip-refresh] [--mcp-host=none|claude|codex|both]
bash skills/scripts/bootstrap-reverse.sh --list
Capabilities (parity with bootstrap-reverse.ps1):
@@ -180,14 +186,16 @@ Capabilities (parity with bootstrap-reverse.ps1):
Examples:
bash skills/scripts/bootstrap-reverse.sh jadx apktool frida
bash skills/scripts/bootstrap-reverse.sh jshookmcp reqable-mcp
bash skills/scripts/bootstrap-reverse.sh idapro --start-services
bash skills/scripts/bootstrap-reverse.sh jshookmcp --mcp-host=codex
bash skills/scripts/bootstrap-reverse.sh reqable-mcp --mcp-host=claude
bash skills/scripts/bootstrap-reverse.sh idapro --start-services --mcp-host=both
bash skills/scripts/bootstrap-reverse.sh burpsuite-mcp
Notes:
- This script supports Linux and macOS.
- It writes MCP config to ~/.claude/mcp.json by default.
- Override with CLAUDE_MCP_CONFIG=/path/to/mcp.json.
- MCP host registration is opt-in. The default is --mcp-host=none and does not write client-global config.
- Explicit Claude registration uses CLAUDE_MCP_CONFIG or ~/.claude/mcp.json.
- Explicit Codex registration uses CODEX_CONFIG_PATH or ~/.codex/config.toml.
- Override install root with REVERSE_SKILL_TOOLS_DIR=~/tools.
EOF
}
@@ -294,8 +302,6 @@ ensure_pnpm() {
fi
}
# Args: repo regex [release_tag]
# Prints: url\tdigest_or_empty
latest_github_asset_meta() {
local repo="$1"
local regex="$2"
@@ -326,8 +332,6 @@ latest_github_asset_url() {
latest_github_asset_meta "$repo" "$regex" "$tag" | cut -f1
}
# verify_sha256 file expected_or_empty [github_digest]
# expected may be "hex" or "sha256:hex"; github_digest same. Prefer expected.
verify_sha256() {
local file="$1"
local expected="$2"
@@ -365,18 +369,9 @@ extract_archive() {
safe_remove_install_dir "$dest" "$dest.tmp"
mkdir -p "$dest"
case "$archive" in
*.zip)
unzip -q "$archive" -d "$dest.tmp"
;;
*.tar.gz|*.tgz)
mkdir -p "$dest.tmp"
tar -xzf "$archive" -C "$dest.tmp"
;;
*)
mkdir -p "$dest"
cp "$archive" "$dest/"
return 0
;;
*.zip) unzip -q "$archive" -d "$dest.tmp" ;;
*.tar.gz|*.tgz) mkdir -p "$dest.tmp"; tar -xzf "$archive" -C "$dest.tmp" ;;
*) mkdir -p "$dest"; cp "$archive" "$dest/"; return 0 ;;
esac
local top_count
top_count=$(find "$dest.tmp" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')
@@ -388,7 +383,6 @@ extract_archive() {
case "$dest.tmp" in /tmp/reverse-bootstrap-*|"$TOOLS_ROOT"/*.tmp) rm -rf "$dest.tmp" ;; esac
}
# install_github_release repo regex dest [release_tag] [expected_sha256]
install_github_release() {
local repo="$1"
local regex="$2"
@@ -490,11 +484,11 @@ PY
fi
}
write_mcp_server() {
write_claude_mcp_server() {
local name="$1"
local json_payload="$2"
ensure_dir "$(dirname "$MCP_CONFIG_PATH")"
python3 - "$MCP_CONFIG_PATH" "$name" "$json_payload" <<'PY'
ensure_dir "$(dirname "$CLAUDE_MCP_CONFIG_PATH")"
python3 - "$CLAUDE_MCP_CONFIG_PATH" "$name" "$json_payload" <<'PY'
import json, pathlib, sys
path = pathlib.Path(sys.argv[1])
name = sys.argv[2]
@@ -508,9 +502,80 @@ else:
data = {}
data.setdefault('mcpServers', {})[name] = payload
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
print(path)
PY
log_ok "MCP server '$name' registered in $MCP_CONFIG_PATH"
log_ok "MCP server '$name' registered for Claude in $CLAUDE_MCP_CONFIG_PATH"
}
write_codex_mcp_server() {
local name="$1"
local json_payload="$2"
ensure_dir "$(dirname "$CODEX_MCP_CONFIG_PATH")"
python3 - "$CODEX_MCP_CONFIG_PATH" "$name" "$json_payload" <<'PY'
import json, pathlib, re, sys
path = pathlib.Path(sys.argv[1])
name = sys.argv[2]
payload = json.loads(sys.argv[3])
lines = path.read_text(encoding='utf-8').splitlines() if path.exists() else []
header = re.compile(r'^\s*\[mcp_servers\.([^\].]+)(?:\.env)?\]\s*$')
out = []
skip = False
for line in lines:
match = header.match(line)
if line.lstrip().startswith('['):
if match and match.group(1) == name:
skip = True
continue
if skip:
skip = False
if not skip:
out.append(line)
while out and not out[-1].strip():
out.pop()
if out:
out.append('')
def literal(value):
if isinstance(value, bool):
return 'true' if value else 'false'
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, list):
return '[' + ', '.join(literal(v) for v in value) + ']'
text = str(value).replace('\\', '\\\\').replace('"', '\\"')
return f'"{text}"'
out.append(f'[mcp_servers.{name}]')
for key in ('type', 'url', 'command', 'args', 'bearer_token_env_var'):
if key in payload:
out.append(f'{key} = {literal(payload[key])}')
for key in sorted(k for k in payload if k not in {'type','url','command','args','bearer_token_env_var','env','headers'}):
out.append(f'{key} = {literal(payload[key])}')
env = payload.get('env')
if isinstance(env, dict) and env:
out.append('')
out.append(f'[mcp_servers.{name}.env]')
for key in sorted(env):
out.append(f'{key} = {literal(env[key])}')
path.write_text('\n'.join(out) + '\n', encoding='utf-8')
PY
log_ok "MCP server '$name' registered for Codex in $CODEX_MCP_CONFIG_PATH"
}
write_mcp_server() {
local name="$1"
local json_payload="$2"
case "$MCP_HOST_TARGET" in
none)
LAST_CAPABILITY_REGISTRATION_REQUIRED=true
log_warn "MCP registration skipped for '$name' (client-neutral default). Re-run with --mcp-host=claude, codex, or both."
;;
claude) write_claude_mcp_server "$name" "$json_payload" ;;
codex) write_codex_mcp_server "$name" "$json_payload" ;;
both)
write_claude_mcp_server "$name" "$json_payload"
write_codex_mcp_server "$name" "$json_payload"
;;
esac
}
test_tcp_port() {
@@ -518,9 +583,7 @@ test_tcp_port() {
python3 - "$port" <<'PY' >/dev/null 2>&1
import socket, sys
port=int(sys.argv[1])
s=socket.socket()
s.settimeout(1)
s.connect(('127.0.0.1', port))
s=socket.socket(); s.settimeout(1); s.connect(('127.0.0.1', port))
PY
}
@@ -529,15 +592,9 @@ test_mcp_http() {
local timeout_seconds="${2:-3}"
python3 - "$port" "$timeout_seconds" <<'PY' >/dev/null 2>&1
import sys, json, urllib.request
port = int(sys.argv[1])
timeout = int(sys.argv[2])
port = int(sys.argv[1]); timeout = int(sys.argv[2])
body = json.dumps({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/mcp",
data=body,
headers={"Content-Type": "application/json"},
method="POST"
)
req = urllib.request.Request(f"http://127.0.0.1:{port}/mcp", data=body, headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
exit(0 if resp.status == 200 else 1)
@@ -564,10 +621,7 @@ manual_required() {
log_warn "MANUAL_INSTALL_REQUIRED: $name — $hint"
}
is_ready_cmd() {
local cmd="$1"
has_cmd "$cmd"
}
is_ready_cmd() { local cmd="$1"; has_cmd "$cmd"; }
ensure_jeb_pro() {
if has_cmd jeb_wincon || has_cmd jeb; then
@@ -602,8 +656,7 @@ ensure_apktool() {
linux)
if install_apt apktool; then return 0; fi
ensure_dir "$TOOLS_ROOT/apktool"
local meta url digest jar wrapper
local repo tag sha re
local meta url digest jar wrapper repo tag sha re
repo=$(manifest_field apktool repo) || return 1
tag=$(manifest_field apktool releaseTag) || return 1
sha=$(manifest_field apktool assetSha256) || return 1
@@ -669,9 +722,7 @@ ensure_anything_analyzer() {
local repo commit
repo=$(manifest_field anything-analyzer repoUrl) || return 1
commit=$(manifest_field anything-analyzer pinnedCommit) || return 1
if ! has_cmd git; then
case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac
fi
if ! has_cmd git; then case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac; fi
install_git_commit "$repo" "$commit" "$dir" || return 1
ensure_node_runtime || return 1
ensure_pnpm || return 1
@@ -679,20 +730,9 @@ ensure_anything_analyzer() {
if $START_SERVICES; then
(cd "$dir" && pnpm install --frozen-lockfile) || return 1
install_git_commit "$repo" "$commit" "$dir" || return 1
(
cd "$dir" || exit 1
if has_cmd nohup; then
nohup pnpm dev >/tmp/anything-analyzer.log 2>&1 &
else
pnpm dev >/tmp/anything-analyzer.log 2>&1 &
fi
)
(cd "$dir" || exit 1; if has_cmd nohup; then nohup pnpm dev >/tmp/anything-analyzer.log 2>&1 & else pnpm dev >/tmp/anything-analyzer.log 2>&1 & fi)
if wait_for_port 23816 120; then
if test_mcp_http 23816; then
log_ok "anything-analyzer MCP server ready on port 23816 (HTTP verified)"
else
log_warn "anything-analyzer port 23816 open but MCP HTTP handshake failed; check /tmp/anything-analyzer.log"
fi
if test_mcp_http 23816; then log_ok "anything-analyzer MCP server ready on port 23816 (HTTP verified)"; else log_warn "anything-analyzer port 23816 open but MCP HTTP handshake failed; check /tmp/anything-analyzer.log"; fi
else
log_warn "anything-analyzer did not open port 23816; see /tmp/anything-analyzer.log"
fi
@@ -704,19 +744,11 @@ ensure_idapro() {
write_mcp_server "idapro" '{"url":"http://127.0.0.1:13337/mcp"}'
if $START_SERVICES; then
case "$PLATFORM" in
linux)
log_warn "Linux package does not include an IDA GUI launcher. Start IDA manually and ensure MCP listens on 127.0.0.1:13337."
;;
macos)
log_warn "Start IDA Pro manually on macOS and confirm MCP port in the IDA Output window."
;;
linux) log_warn "Linux package does not include an IDA GUI launcher. Start IDA manually and ensure MCP listens on 127.0.0.1:13337." ;;
macos) log_warn "Start IDA Pro manually on macOS and confirm MCP port in the IDA Output window." ;;
esac
wait_for_port 13337 45 || log_warn "idapro MCP service is not online on port 13337 yet."
if test_mcp_http 13337 3; then
log_ok "idapro MCP server ready (HTTP verified)"
else
log_warn "idapro port 13337 open but MCP HTTP handshake failed; check IDA Output window"
fi
if test_mcp_http 13337 3; then log_ok "idapro MCP server ready (HTTP verified)"; else log_warn "idapro port 13337 open but MCP HTTP handshake failed; check IDA Output window"; fi
fi
}
@@ -724,10 +756,7 @@ ensure_r2() {
if has_cmd r2; then log_ok "r2 ready: $(cmd_path r2)"; return 0; fi
case "$PLATFORM" in
macos) install_brew radare2 ;;
linux)
if install_apt radare2; then return 0; fi
manual_required r2 "Install radare2 from GitHub/source: https://github.com/radareorg/radare2"
;;
linux) if install_apt radare2; then return 0; fi; manual_required r2 "Install radare2 from GitHub/source: https://github.com/radareorg/radare2" ;;
esac
}
@@ -756,17 +785,8 @@ ensure_ghidra_mcp() {
repo=$(manifest_field ghidra-mcp repo) || return 1
regex=$(manifest_field ghidra-mcp assetRegex) || return 1
case "$PLATFORM" in
macos)
if ! has_cmd ghidraRun && [[ ! -d /Applications/Ghidra.app ]]; then
install_brew ghidra || brew install --cask ghidra || true
fi
;;
linux)
if ! has_cmd ghidraRun; then
install_github_release "$repo" "$regex" "$TOOLS_ROOT/ghidra" || \
manual_required ghidra-mcp "Install Ghidra from GitHub release or Flatpak, then configure ghidra-mcp if used."
fi
;;
macos) if ! has_cmd ghidraRun && [[ ! -d /Applications/Ghidra.app ]]; then install_brew ghidra || brew install --cask ghidra || true; fi ;;
linux) if ! has_cmd ghidraRun; then install_github_release "$repo" "$regex" "$TOOLS_ROOT/ghidra" || manual_required ghidra-mcp "Install Ghidra from GitHub release or Flatpak, then configure ghidra-mcp if used."; fi ;;
esac
log_warn "ghidra-mcp requires local Ghidra MCP plugin/server setup. See docs/platforms/$( [[ "$PLATFORM" == macos ]] && echo macos || echo linux ).md"
}
@@ -787,12 +807,7 @@ ensure_proxycat() {
local repo commit
repo=$(manifest_field proxycat repo) || return 1
commit=$(manifest_field proxycat pinnedCommit) || return 1
pipx install "git+${repo}@${commit}" || {
manual_required proxycat "Clone/install ProxyCat manually; verify command 'proxycat'."
LAST_CAPABILITY_MANUAL=true
MANUAL_REQUIRED=true
return 0
}
pipx install "git+${repo}@${commit}" || { manual_required proxycat "Clone/install ProxyCat manually; verify command 'proxycat'."; LAST_CAPABILITY_MANUAL=true; MANUAL_REQUIRED=true; return 0; }
}
ensure_burpsuite_mcp() {
@@ -825,28 +840,16 @@ PY
ensure_pentestswarm() {
local pentestswarm_path
pentestswarm_path="$(cmd_path pentestswarm)"
if [[ -n "$pentestswarm_path" ]]; then
register_pentestswarm_mcp "$pentestswarm_path"
log_ok "pentestswarm ready"
return 0
fi
if ! has_cmd go; then
case "$PLATFORM" in macos) install_brew go ;; linux) install_apt golang-go ;; esac
fi
if [[ -n "$pentestswarm_path" ]]; then register_pentestswarm_mcp "$pentestswarm_path"; log_ok "pentestswarm ready"; return 0; fi
if ! has_cmd go; then case "$PLATFORM" in macos) install_brew go ;; linux) install_apt golang-go ;; esac; fi
local go_package docker_image
go_package=$(manifest_field pentestswarm goPackage) || return 1
docker_image=$(manifest_field pentestswarm dockerImage) || return 1
if go install "$go_package"; then
local go_bin
go_bin="$(go env GOBIN 2>/dev/null || true)"
if [[ -z "$go_bin" ]]; then
go_bin="$(go env GOPATH 2>/dev/null || true)/bin"
fi
if [[ -x "$go_bin/pentestswarm" ]]; then
register_pentestswarm_mcp "$go_bin/pentestswarm"
log_ok "pentestswarm installed and registered"
return 0
fi
if [[ -z "$go_bin" ]]; then go_bin="$(go env GOPATH 2>/dev/null || true)/bin"; fi
if [[ -x "$go_bin/pentestswarm" ]]; then register_pentestswarm_mcp "$go_bin/pentestswarm"; log_ok "pentestswarm installed and registered"; return 0; fi
log_warn "pentestswarm installed but no executable was found in GOBIN/GOPATH; trying Docker fallback"
fi
if has_cmd docker; then
@@ -855,7 +858,7 @@ import json, sys
print(json.dumps({'command':'docker','args':['run','--rm','-i',sys.argv[1],'mcp','serve']}))
PY
)"
log_warn "pentestswarm Go install failed or produced no runnable binary; registered Docker fallback $docker_image"
log_warn "pentestswarm Go install failed or produced no runnable binary; prepared Docker fallback $docker_image"
else
manual_required pentestswarm "Install Go 1.24+ or Docker, then install Pentest-Swarm-AI and ensure pentestswarm is on PATH."
fi
@@ -863,18 +866,12 @@ PY
ensure_binwalk() {
if has_cmd binwalk; then log_ok "binwalk ready: $(cmd_path binwalk)"; return 0; fi
case "$PLATFORM" in
macos) install_brew binwalk ;;
linux) install_apt binwalk || manual_required binwalk "git clone https://github.com/ReFirmLabs/binwalk.git and install manually" ;;
esac
case "$PLATFORM" in macos) install_brew binwalk ;; linux) install_apt binwalk || manual_required binwalk "git clone https://github.com/ReFirmLabs/binwalk.git and install manually" ;; esac
}
ensure_yara() {
if has_cmd yara; then log_ok "yara ready: $(cmd_path yara)"; return 0; fi
case "$PLATFORM" in
macos) install_brew yara ;;
linux) install_apt yara || manual_required yara "Install from source: https://github.com/VirusTotal/yara" ;;
esac
case "$PLATFORM" in macos) install_brew yara ;; linux) install_apt yara || manual_required yara "Install from source: https://github.com/VirusTotal/yara" ;; esac
}
ensure_pwntools() {
@@ -889,11 +886,7 @@ status_json_line() {
local name="$1"
local status="$2"
local extra="${3:-}"
if [[ -n "$extra" ]]; then
printf '{"name":"%s","status":"%s","note":"%s"}\n' "$name" "$status" "$extra"
else
printf '{"name":"%s","status":"%s"}\n' "$name" "$status"
fi
if [[ -n "$extra" ]]; then printf '{"name":"%s","status":"%s","note":"%s"}\n' "$name" "$status" "$extra"; else printf '{"name":"%s","status":"%s"}\n' "$name" "$status"; fi
}
cap_depends() {
@@ -911,10 +904,7 @@ expand_capabilities() {
local cap dep
for cap in "$@"; do
for dep in $(cap_depends "$cap"); do
if [[ "$seen" != *" $dep "* ]]; then
out+=("$dep")
seen+="$dep "
fi
if [[ "$seen" != *" $dep "* ]]; then out+=("$dep"); seen+="$dep "; fi
done
done
printf '%s\n' "${out[@]}"
@@ -951,15 +941,13 @@ ensure_capability() {
RESULTS_FILE="$(mktemp)"
trap 'rm -f "$RESULTS_FILE"' EXIT
# macOS ships Bash 3.2, which has no mapfile/readarray. Keep this path portable
# instead of requiring users to install a newer Bash just to run the bootstrapper.
EXPANDED=()
while IFS= read -r capability; do
[[ -n "$capability" ]] || continue
EXPANDED+=("$capability")
done < <(expand_capabilities "${CAPABILITIES[@]}")
log_info "platform=$PLATFORM doc=$(platform_doc) tools_root=$TOOLS_ROOT"
log_info "platform=$PLATFORM doc=$(platform_doc) tools_root=$TOOLS_ROOT mcp_host=$MCP_HOST_TARGET"
if ! ensure_python_interpreter; then
log_err "Python 3 is required to read bootstrap-manifest.json; no capability was executed."
@@ -969,9 +957,12 @@ fi
for cap in "${EXPANDED[@]}"; do
log_info "ensure $cap"
LAST_CAPABILITY_MANUAL=false
LAST_CAPABILITY_REGISTRATION_REQUIRED=false
if ensure_capability "$cap"; then
if $LAST_CAPABILITY_MANUAL; then
status_json_line "$cap" "manual-required" "see $(platform_doc)" >> "$RESULTS_FILE"
elif $LAST_CAPABILITY_REGISTRATION_REQUIRED; then
status_json_line "$cap" "registration-required" "re-run with --mcp-host=claude, codex, or both" >> "$RESULTS_FILE"
else
status_json_line "$cap" "ready" >> "$RESULTS_FILE"
fi