From 919908885ab8a1b9f36c9720aa3925f7dc4d3473 Mon Sep 17 00:00:00 2001 From: jhuang-tw <77732008+jhuang-tw@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:11:19 -0700 Subject: [PATCH] fix: make Bash MCP host registration explicit --- skills/scripts/bootstrap-reverse.sh | 259 ++++++++++++++-------------- 1 file changed, 125 insertions(+), 134 deletions(-) diff --git a/skills/scripts/bootstrap-reverse.sh b/skills/scripts/bootstrap-reverse.sh index fae4b01..db34c24 100644 --- a/skills/scripts/bootstrap-reverse.sh +++ b/skills/scripts/bootstrap-reverse.sh @@ -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 [capability2] ... [--start-services] [--skip-refresh] +# bash skills/scripts/bootstrap-reverse.sh [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 [capability2] ... [--start-services] [--skip-refresh] + bash skills/scripts/bootstrap-reverse.sh [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