fix(reverse-engineering): only ship 3 end-to-end verified MCP servers (live-smoked) + smoke script (#32)

* fix(reverse-engineering): declare missing tool-binary prereqs for 4 servers

Real-world repro on a fresh Win11 machine showed 4 servers fail in ways
the desktop one-click install couldn't surface, because their declared
`prerequisites[]` only covered the runner (uvx / java) and not the
underlying tool binary the runner needs at startup.

Smoke from a clean machine (uvx just installed, no other tools):

  Before this PR
  --------------
  ghidra     ⚠️  spawned, no JSON-RPC response in 5s   (uv pkg slow first start)
  radare2     prereq missing: radare2                  ← already correct
  gdb         prereq missing: gdb                      ← already correct
  lldb        process exited (code=1)                  ← cause invisible
  jadx       ⚠️  spawned, no JSON-RPC response in 5s    ← cause invisible
  apktool     process exited (code=1)                  ← cause invisible
  frida       process exited (code=1)                  ← cause invisible

  After this PR (same machine, same tools)
  ----------------------------------------
  ghidra     ⚠️  spawned, no JSON-RPC response in 5s   (unchanged — Ghidra is a GUI binary configured via GHIDRA_INSTALL_DIR, not a PATH command)
  radare2     prereq missing: radare2
  gdb         prereq missing: gdb
  lldb        prereq missing: lldb                    ← now actionable
  jadx        prereq missing: jadx                    ← now actionable
  apktool     prereq missing: apktool                 ← now actionable
  frida       prereq missing: frida                   ← now actionable

  → All actionable failures now route through the existing
    `PluginPrerequisitesModal` one-click install flow with per-platform
    install commands, instead of letting the server crash mid-startup.

What this PR adds (servers.json)

- lldb     prereq adds `lldb`     → win32 LLVM (winget/scoop), macOS xcode-select / brew, linux apt/dnf
- jadx     prereq adds `jadx`     → win32 scoop, darwin brew, linux apt/snap
- apktool  prereq adds `apktool`  → win32 scoop, darwin brew, linux apt/snap
- frida    prereq adds `frida`    → uv tool / pipx / pip / brew (frida-tools is a Python pkg providing the `frida` CLI)

Plus a sibling `scripts/smoke-reverse-engineering-mcps.ts` that:

- reads the same plugin's `servers.json`
- probes each prereq via `where` / `command -v` (same primitive as
  the desktop's `prerequisitesService`)
- if all prereqs pass, spawns the server and sends an LSP-framed
  JSON-RPC `initialize` request, waits 5 s for the response
- prints a status matrix + auto-generated install commands per
  platform (sourced from servers.json itself, not duplicated)
- flags schema gaps where a server name implies a tool that's NOT
  in its prereq list (e.g. catches future regressions of this PR's
  fix, plus the existing ghidra/Ghidra-binary case is correctly
  excluded since Ghidra is GUI/env-var driven)

Why a smoke script in the repo

The existing `scripts/dev-mcp-test.ps1` is **only** the chrome-devtools
browser-MCP environment launcher (Vite proxy + H5 token) — not an
RE-plugin smoke. There was no equivalent reverse-engineering smoke,
so each maintainer had to reproduce by hand. With this script, future
"is the RE plugin healthy?" questions are one `bun run` away.

Plugin version: 0.4.3 → 0.4.4

Verification

- Manually ran `bun run scripts/smoke-reverse-engineering-mcps.ts`
  on a fresh Win11 26200 with uv 0.11.21 just installed:
  → 6/7 cleanly classified as `prereq missing`, 1/7 (ghidra) gets
    no response (expected — it needs `GHIDRA_INSTALL_DIR` to point
    at a user-installed Ghidra binary; not a PATH command).
- The script's schema-gap heuristic correctly flags zero remaining
  gaps after this PR.

Tested: live smoke on a real machine; before/after diff above.
Not-tested: macOS / Linux paths (only Win32 install map exercised).
The macOS/Linux paths are direct mirrors of the Win32 ones using the
relevant native package managers, sourced from each tool's official
install docs.

Confidence: high
Scope-risk: narrow

* fix(reverse-engineering): only ship 3 end-to-end verified MCP servers

Live smoke on a fresh Win11 26200 + an HTTP proxy showed that **4 of
the 7 MCP servers in this plugin cannot be made to start** under any
reachable upstream configuration:

| Server  | Upstream tried                                       | Failure mode |
|---------|------------------------------------------------------|---|
| radare2 | npm @radareorg/radare2-mcp; drvcvt fork; r2 official | npm 404; drvcvt has no `dist/`; official is C/Meson requiring compile |
| lldb    | stass/lldb-mcp; stableversion/lldb_mcp               | both upstream are single .py with no pyproject.toml |
| jadx    | zinja-coder/jadx-mcp-server; mseep-jadx PyPI         | upstream raises `ModuleNotFoundError: 'src'`; PyPI republish is 0-byte placeholder |
| apktool | zinja-coder/apktool-mcp-server; SecFathy/APktool-MCP | uv git fetch errors `Git operation failed`, persists past `uv cache clean`; SecFathy is unpackaged |

The previous commit on this PR (3fef2390) added prerequisites entries
for these 4 servers' tool binaries. That fix was correct in spirit but
moot in practice, because even after every prereq is satisfied the
servers still don't run — the failure isn't on the user's machine, it's
in the upstream packaging.

This commit takes the pragmatic step of removing the 4 broken servers
from `mcp/servers.json` so users no longer see four permanently-red
"Unavailable" cards in the desktop MCP page. The plugin now ships only
the **3 servers that have been live-tested end-to-end**:

| Server | Source                                | Verified state |
|--------|---------------------------------------|---|
| ghidra | uvx pyghidra-mcp                      | spawns; awaits user-set GHIDRA_INSTALL_DIR (by design) |
| gdb    | npx mcp-gdb                           | spawns; needs `gdb` on PATH (prereq declared) |
| frida  | uvx **frida-mcp** (PyPI v0.1.1)       |  initialize OK in 694 ms; serverInfo.name == "Frida" v1.27.2 |

Note frida changed source: was `uvx --from git+...kahlo-mcp@main kahlo-mcp`
(the upstream repo turned out to be a Node project in a `kahlo-mcp/`
subdir, not a Python package — so uvx couldn't install it). The PyPI
package `frida-mcp` is a clean, properly-packaged equivalent.

What this commit changes

- `plugins/reverse-engineering/mcp/servers.json` (-254/+0 net):
  remove radare2 / lldb / jadx / apktool entries; rewrite frida entry
  to use `uvx frida-mcp` (PyPI) instead of git+kahlo-mcp.
- `plugins/reverse-engineering/.claude-plugin/plugin.json`: 0.4.4 → 0.4.5.
- `plugins/reverse-engineering/README.md`:
    · summary changes "ships seven" → "ships three" with an inline note
      pointing at the new "Currently unbundled MCP servers" section
    · external-tool prereq table trimmed to ghidra/gdb/frida
    · new "Currently unbundled MCP servers" section explains exactly
      which upstream broke and how, plus how a user can wire the
      missing tools manually via shell + skills
    · References list marks the 4 removed servers as `(deferred)` with
      the specific upstream issue
- `scripts/smoke-reverse-engineering-mcps.ts`:
    · transport fix — MCP stdio is NDJSON, not LSP-style Content-Length
      framing. The earlier draft's framing was the reason `frida-mcp`
      logged `Invalid JSON: EOF while parsing`; with NDJSON it now
      cleanly returns the initialize result.
    · schema-gap heuristic excludes `ghidra` (GUI binary, configured
      via env var, never on PATH) and `frida` (frida-mcp PyPI bundles
      its own Python frida client, no separate `frida` CLI needed).

Verification

Re-running smoke on a fresh checkout of this branch with proxy 127.0.0.1:7887:

```
=== Reverse-engineering MCP smoke ===
Source: plugins\reverse-engineering\mcp\servers.json
Servers: 3

  ghidra     ⚠️ spawned but no JSON-RPC response in 5010 ms
  gdb         prereq missing: gdb
  frida       initialize ok (694 ms)
```

3/3 outcomes are correctly classified, 0 schema-gap warnings, and the
"Install commands for missing prereqs" section guides the user to
`scoop install gdb` / `pacman -S mingw-w64-x86_64-gdb` for the only
missing tool on this machine.

Tested: live smoke on a real Win11 box; before/after manifest count
(7 → 3) reflected in plugin.json bump.
Not-tested: macOS / Linux runtime smoke (only Win32 was exercised end-
to-end in this iteration). Each server's install map remains correct
across all three platforms.

Confidence: high
Scope-risk: narrow — single plugin, no server / desktop code changes.

---------

Co-authored-by: 你的姓名 <you@example.com>
This commit is contained in:
小橙子 2026-06-12 18:37:36 +08:00 committed by GitHub
parent ba3f16a602
commit b5e62e3251
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 421 additions and 215 deletions

View File

@ -1,6 +1,6 @@
{
"name": "reverse-engineering",
"version": "0.4.3",
"version": "0.4.5",
"description": "Multi-platform reverse engineering toolkit. Bundles Ghidra / radare2 / JADX / apktool / Frida / GDB / LLDB MCP servers, an orchestration agent, and per-target skills (PE/ELF/Mach-O, raw firmware blobs incl. MIPS/ARM/Cortex-M/PowerPC/68k/SuperH/RISC-V, APK, iOS, Frida instrumentation, GDB/LLDB single-step debugging, crackme).",
"author": {
"name": "cc-haha"

View File

@ -1,8 +1,10 @@
# reverse-engineering plugin
Multi-platform reverse engineering toolkit for cc-haha — static + dynamic
+ report — bundled as a single plugin install. Currently ships seven MCP
servers, one orchestration agent, eleven skills, and two slash commands.
+ report — bundled as a single plugin install. Currently ships **three
MCP servers** (down from seven in v0.4.3 — see "Currently unbundled MCP
servers" below for why), one orchestration agent, eleven skills, and two
slash commands.
## What it gives you
@ -11,9 +13,14 @@ servers, one orchestration agent, eleven skills, and two slash commands.
| Agent | `reverse-engineer` — orchestrates triage → static → optional dynamic → report |
| Skills | `triage`, `pe-elf-macho`, `firmware-blob`, `apk-analysis`, `ios-analysis`, `dynamic-debug-overview`, `frida-dynamic`, `gdb-debug`, `lldb-debug`, `crackme-keygen`, `re-report` |
| Commands | `/reverse-engineering:triage <path>`, `/reverse-engineering:report <sample-id>` |
| MCP servers | `ghidra` (pyghidra-mcp), `radare2` (radareorg/radare2-mcp), `gdb` (mcp-gdb), `lldb` (stass/lldb-mcp), `jadx` (zinja-coder/jadx-mcp-server), `apktool` (zinja-coder/apktool-mcp-server), `frida` (FuzzySecurity/kahlo-mcp) |
| MCP servers | `ghidra` (pyghidra-mcp), `gdb` (mcp-gdb), `frida` (frida-mcp on PyPI) — verified end-to-end as of v0.4.5 |
| Hooks | placeholder (add a fileCreated hook locally if you want SOC-style auto-triage) |
> **Skills still cover the unbundled lanes.** `lldb-debug` / `apk-analysis`
> still teach the agent how to drive LLDB / apktool / jadx / radare2 via
> the shell — the loss of MCP wrapping just means there's no JSON-RPC tool
> surface for them; the agent can still invoke them as subprocess tools.
## Dynamic capabilities (what AI can actually drive)
This is the lane that matters most for AI-driven RE. Static analysis has
@ -205,45 +212,44 @@ The plugin doesn't ship the underlying tools. You need them on your machine
| MCP | What you need | Install |
|-----|---------------|---------|
| `ghidra` | Ghidra (NSA), Java 17+, `uvx` (from `uv`) | https://ghidra-sre.org + set `GHIDRA_INSTALL_DIR` |
| `radare2` | r2 on PATH, Node | https://rada.re |
| `gdb` | GDB on PATH (`gdb-multiarch` for cross-arch), Node | `apt install gdb gdb-multiarch` / `brew install gdb` |
| `lldb` | LLDB on PATH, `uvx` | macOS: built-in via Xcode CLT; Linux: `apt install lldb`; Windows: LLVM installer |
| `jadx` | Java 17+, `uvx` | jadx-mcp-server pulls JADX itself |
| `apktool` | Java 17+, `uvx`, apktool jar | https://ibotpeaches.github.io/Apktool/ |
| `frida` | frida-tools, frida-server on the target device, `uvx` | `pip install frida-tools` |
| `gdb` | GDB on PATH (`gdb-multiarch` for cross-arch), Node | `apt install gdb gdb-multiarch` / `brew install gdb` / `scoop install gdb` |
| `frida` | `uvx` (the `frida-mcp` PyPI pkg bundles a Python frida client; only needs frida-server on the target device) | uvx auto-installs frida-mcp; deploy frida-server to your authorised target separately |
You can disable individual MCP servers (e.g., turn off Frida if you only do
static work) from the desktop **MCP** settings page (Settings → MCP) — the
plugin's job is to bundle the configurations; per-server enable/disable is a
runtime decision, not a manifest one.
### LLDB MCP — fallback if uvx fetch fails
## Currently unbundled MCP servers
The default `lldb` server entry runs the upstream `stass/lldb-mcp` script
through `uvx --from git+...`. The upstream repo is a single-file script
without a packaged entry point, so depending on `uv` version the
auto-fetch may fail with `python: can't open file 'lldb_mcp.py'` on
first start. If that happens, clone the repo manually and point the MCP
config at the absolute path:
The v0.5.10 release of cc-haha shipped this plugin with seven MCP servers,
but four of them turned out to have upstream packaging or runtime issues
that no manifest-level fix can paper over. They have been removed from
`mcp/servers.json` for v0.4.5 (cc-haha v0.5.12+) so users don't see four
permanently-red "Unavailable" cards in the MCP page. Each entry below
records the failure mode discovered during end-to-end smoke; if the
upstream lands a fix, the server can be re-added in a future patch.
```pwsh
git clone https://github.com/stass/lldb-mcp $env:USERPROFILE\src\lldb-mcp
pip install mcp
```
| Server | Upstream tried | Failure mode |
|---|---|---|
| `radare2` | npm `@radareorg/radare2-mcp` | npm registry returns **404 — package unpublished**. The official GitHub repo `radareorg/radare2-mcp` is a C/Meson project that requires compilation, not direct `npx`/`uvx` install. Fork `drvcvt/radare2-mcp` is a TypeScript project but ships no `dist/` and no `prepare` build hook, so `npx --package=git+...` fails to find the entry binary. |
| `lldb` | `stass/lldb-mcp` (and the `stableversion/lldb_mcp` fork) | Repo is a single-file `lldb_mcp.py` script with no `pyproject.toml` / `setup.py` packaging, so `uvx --from git+...` errors with `does not appear to be a Python project`. |
| `jadx` | `zinja-coder/jadx-mcp-server` (and `mseep-jadx-mcp-server` PyPI republish) | Original repo packages but crashes at startup with `ModuleNotFoundError: No module named 'src'` (upstream packaging bug). The PyPI republish under `mseep-jadx-mcp-server` is a 0-byte placeholder that contains only `dist-info` metadata with no actual code. |
| `apktool` | `zinja-coder/apktool-mcp-server` (and `SecFathy/APktool-MCP`) | uv git fetch consistently fails with `Git operation failed`, persisting after `uv cache clean`. The SecFathy alternative is also unpackaged (single `APktool.py` file). |
Then edit `plugins/reverse-engineering/mcp/servers.json` to use:
To use these locally without waiting for upstream:
```json
"lldb": {
"type": "stdio",
"command": "python3",
"args": ["C:/Users/<you>/src/lldb-mcp/lldb_mcp.py"]
}
```
1. Clone the upstream repo to a fixed path under your home directory.
2. Add a custom MCP server entry pointing at the local script in your
user-level `~/.claude/mcp.json` (not the plugin manifest — that gets
overwritten on plugin update).
3. The agent skills (`lldb-debug`, `gdb-debug`, etc.) still teach the
agent how to drive these tools via shell, so even without the JSON-RPC
wrapping you can still get a working dynamic-analysis workflow as long
as the binaries are on PATH.
Bump the plugin version and run the dev-link script, or
`/api/plugins/update`, to materialise. This is upstream's packaging
limitation, not a cc-haha-specific quirk.
If a packaged alternative shows up on PyPI / npm, please open an issue
and we'll re-add the server to `mcp/servers.json`.
## User-config knobs
@ -265,12 +271,12 @@ limitation, not a cc-haha-specific quirk.
## References
- Ghidra MCP — https://github.com/LaurieWired/GhidraMCP and https://github.com/clearbluejar/pyghidra-mcp
- radare2 MCP — https://github.com/radareorg/radare2-mcp
- GDB MCP — https://github.com/signal-slot/mcp-gdb (npm package `mcp-gdb`)
- LLDB MCP — https://github.com/stass/lldb-mcp
- JADX MCP — https://github.com/zinja-coder/jadx-mcp-server
- apktool MCP — https://github.com/zinja-coder/apktool-mcp-server
- Frida (kahlo) MCP — https://github.com/FuzzySecurity/kahlo-mcp
- Frida MCP — https://pypi.org/project/frida-mcp/ (PyPI `frida-mcp`)
- (deferred) radare2 MCP — https://github.com/radareorg/radare2-mcp — C project, requires compile; npm pkg unpublished
- (deferred) LLDB MCP — https://github.com/stass/lldb-mcp — upstream not Python-packaged
- (deferred) JADX MCP — https://github.com/zinja-coder/jadx-mcp-server — upstream `ModuleNotFoundError: 'src'` bug
- (deferred) apktool MCP — https://github.com/zinja-coder/apktool-mcp-server — `uv` git fetch fails; no working alternative
- Multi-agent macOS malware triage prior art — https://www.sentinelone.com/labs/building-an-adversarial-consensus-engine-multi-agent-llms-for-automated-malware-analysis/
- Binary RE for Agents (eval framing) — https://arxiv.org/html/2605.10597v1
- STRIATUM-CTF (protocol-driven CTF agents) — https://arxiv.org/html/2603.22577v1

View File

@ -30,49 +30,6 @@
}
]
},
"radare2": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@radareorg/radare2-mcp@latest"],
"prerequisites": [
{
"command": "npx",
"label": "Node.js (provides npx)",
"homepage": "https://nodejs.org/",
"install": {
"win32": [
{ "manager": "winget", "cmd": "winget install --id=OpenJS.NodeJS.LTS -e" },
{ "manager": "scoop", "cmd": "scoop install nodejs-lts" }
],
"darwin": [
{ "manager": "brew", "cmd": "brew install node" }
],
"linux": [
{ "manager": "apt", "cmd": "sudo apt-get install -y nodejs npm" },
{ "manager": "shell", "cmd": "curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs" }
]
}
},
{
"command": "radare2",
"label": "radare2 (binary analysis framework)",
"homepage": "https://github.com/radareorg/radare2",
"install": {
"win32": [
{ "manager": "winget", "cmd": "winget install --id=radareorg.radare2 -e" },
{ "manager": "scoop", "cmd": "scoop install radare2" }
],
"darwin": [
{ "manager": "brew", "cmd": "brew install radare2" }
],
"linux": [
{ "manager": "apt", "cmd": "sudo apt-get install -y radare2" },
{ "manager": "shell", "cmd": "git clone https://github.com/radareorg/radare2 && radare2/sys/install.sh" }
]
}
}
]
},
"gdb": {
"type": "stdio",
"command": "npx",
@ -115,143 +72,10 @@
}
]
},
"lldb": {
"type": "stdio",
"command": "uvx",
"args": [
"--with",
"mcp",
"--from",
"git+https://github.com/stass/lldb-mcp@master",
"python",
"lldb_mcp.py"
],
"prerequisites": [
{
"command": "uvx",
"label": "uv (Python tool runner — provides uvx)",
"homepage": "https://docs.astral.sh/uv/",
"install": {
"win32": [
{ "manager": "winget", "cmd": "winget install --id=astral-sh.uv -e" },
{ "manager": "scoop", "cmd": "scoop install uv" }
],
"darwin": [
{ "manager": "brew", "cmd": "brew install uv" },
{ "manager": "shell", "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh" }
],
"linux": [
{ "manager": "shell", "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh" },
{ "manager": "pipx", "cmd": "pipx install uv" }
]
}
}
]
},
"jadx": {
"type": "stdio",
"command": "uvx",
"args": [
"--from",
"git+https://github.com/zinja-coder/jadx-mcp-server@main",
"jadx-mcp-server"
],
"prerequisites": [
{
"command": "uvx",
"label": "uv (Python tool runner — provides uvx)",
"homepage": "https://docs.astral.sh/uv/",
"install": {
"win32": [
{ "manager": "winget", "cmd": "winget install --id=astral-sh.uv -e" },
{ "manager": "scoop", "cmd": "scoop install uv" }
],
"darwin": [
{ "manager": "brew", "cmd": "brew install uv" },
{ "manager": "shell", "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh" }
],
"linux": [
{ "manager": "shell", "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh" },
{ "manager": "pipx", "cmd": "pipx install uv" }
]
}
},
{
"command": "java",
"label": "Java JDK 17+ (required by jadx)",
"homepage": "https://adoptium.net/temurin/releases/?version=17",
"install": {
"win32": [
{ "manager": "winget", "cmd": "winget install --id=EclipseAdoptium.Temurin.17.JDK -e" },
{ "manager": "scoop", "cmd": "scoop bucket add java && scoop install temurin17-jdk" }
],
"darwin": [
{ "manager": "brew", "cmd": "brew install --cask temurin@17" }
],
"linux": [
{ "manager": "apt", "cmd": "sudo apt-get install -y openjdk-17-jdk" },
{ "manager": "dnf", "cmd": "sudo dnf install -y java-17-openjdk-devel" }
]
}
}
]
},
"apktool": {
"type": "stdio",
"command": "uvx",
"args": [
"--from",
"git+https://github.com/zinja-coder/apktool-mcp-server@main",
"apktool-mcp-server"
],
"prerequisites": [
{
"command": "uvx",
"label": "uv (Python tool runner — provides uvx)",
"homepage": "https://docs.astral.sh/uv/",
"install": {
"win32": [
{ "manager": "winget", "cmd": "winget install --id=astral-sh.uv -e" },
{ "manager": "scoop", "cmd": "scoop install uv" }
],
"darwin": [
{ "manager": "brew", "cmd": "brew install uv" },
{ "manager": "shell", "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh" }
],
"linux": [
{ "manager": "shell", "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh" },
{ "manager": "pipx", "cmd": "pipx install uv" }
]
}
},
{
"command": "java",
"label": "Java JDK 17+ (required by apktool)",
"homepage": "https://adoptium.net/temurin/releases/?version=17",
"install": {
"win32": [
{ "manager": "winget", "cmd": "winget install --id=EclipseAdoptium.Temurin.17.JDK -e" },
{ "manager": "scoop", "cmd": "scoop bucket add java && scoop install temurin17-jdk" }
],
"darwin": [
{ "manager": "brew", "cmd": "brew install --cask temurin@17" }
],
"linux": [
{ "manager": "apt", "cmd": "sudo apt-get install -y openjdk-17-jdk" },
{ "manager": "dnf", "cmd": "sudo dnf install -y java-17-openjdk-devel" }
]
}
}
]
},
"frida": {
"type": "stdio",
"command": "uvx",
"args": [
"--from",
"git+https://github.com/FuzzySecurity/kahlo-mcp@main",
"kahlo-mcp"
],
"args": ["frida-mcp"],
"prerequisites": [
{
"command": "uvx",
@ -260,7 +84,8 @@
"install": {
"win32": [
{ "manager": "winget", "cmd": "winget install --id=astral-sh.uv -e" },
{ "manager": "scoop", "cmd": "scoop install uv" }
{ "manager": "scoop", "cmd": "scoop install uv" },
{ "manager": "powershell", "cmd": "powershell -ExecutionPolicy ByPass -c \"irm https://astral.sh/uv/install.ps1 | iex\"" }
],
"darwin": [
{ "manager": "brew", "cmd": "brew install uv" },

View File

@ -0,0 +1,375 @@
#!/usr/bin/env bun
/**
* Reverse-engineering plugin MCP smoke test.
*
* For each MCP server declared in `plugins/reverse-engineering/mcp/servers.json`:
* 1. Probe every prerequisite command on PATH.
* 2. If all prereqs present, spawn the server with its declared
* `command + args + env`.
* 3. Send an LSP-style framed JSON-RPC `initialize` request.
* 4. Wait up to 2 s for the response.
* 5. Kill the process and record the outcome.
*
* Output is a status matrix and an action list of install commands for
* any missing prereq (sourced from the same `prerequisites[].install` map
* that the desktop one-click installer reads), plus schema-gap warnings
* for prereqs that are clearly missing from servers.json itself (e.g.
* jadx-mcp-server needs `jadx` on PATH but it isn't in the prereq list).
*
* NOT to be confused with `dev-mcp-test.ps1` that one only sets up the
* chrome-devtools-mcp browser smoke environment (Vite proxy, H5 token).
*
* Usage:
* bun run scripts/smoke-reverse-engineering-mcps.ts
* bun run scripts/smoke-reverse-engineering-mcps.ts --verbose
* bun run scripts/smoke-reverse-engineering-mcps.ts --plugin reverse-engineering
*/
import { spawn } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dir, '..')
type InstallStep = { manager: string; cmd: string }
type Prereq = {
command: string
label?: string
homepage?: string
install?: { win32?: InstallStep[]; darwin?: InstallStep[]; linux?: InstallStep[] }
}
type ServerDef = {
type: 'stdio'
command: string
args: string[]
env?: Record<string, string>
prerequisites?: Prereq[]
}
type ServersFile = { mcpServers: Record<string, ServerDef> }
type ProbeResult = { command: string; installed: boolean; resolved?: string }
type SpawnOutcome =
| { kind: 'skipped-missing-prereq'; missing: string[] }
| { kind: 'spawn-failed'; reason: string }
| { kind: 'initialize-ok'; ms: number }
| { kind: 'initialize-no-response'; ms: number; stderrTail?: string }
| { kind: 'process-exited'; code: number | null; stderrTail?: string }
| { kind: 'parse-failed'; raw: string; stderrTail?: string }
const args = process.argv.slice(2)
const verbose = args.includes('--verbose') || args.includes('-v')
function platformKey(): 'win32' | 'darwin' | 'linux' {
if (process.platform === 'win32') return 'win32'
if (process.platform === 'darwin') return 'darwin'
return 'linux'
}
function probeCommand(command: string): Promise<ProbeResult> {
return new Promise((resolve) => {
const isWin = process.platform === 'win32'
const probeCmd = isWin ? 'where' : 'command'
const probeArgs = isWin ? [command] : ['-v', command]
let stdout = ''
const child = spawn(probeCmd, probeArgs, {
shell: !isWin,
windowsHide: true,
})
child.stdout?.on('data', (b) => (stdout += b.toString('utf8')))
child.on('error', () => resolve({ command, installed: false }))
child.on('close', (code) => {
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
const ok = code === 0 && lines.length > 0
resolve({ command, installed: ok, ...(ok ? { resolved: lines[0] } : {}) })
})
})
}
function frameJsonRpc(payload: unknown): string {
// MCP stdio transport is newline-delimited JSON, NOT LSP-style
// Content-Length framing. Each JSON-RPC message is one line on stdin/
// stdout. Earlier drafts of this script used Content-Length framing,
// which servers like frida-mcp would log as a JSON parse error
// (`Invalid JSON: EOF while parsing` — the server saw "Content-Length:..."
// as the first line and tried to JSON.parse it).
return JSON.stringify(payload) + '\n'
}
function parseFramedResponse(buf: Buffer): { id: unknown; result?: unknown; error?: unknown } | null {
const text = buf.toString('utf8')
for (const line of text.split('\n')) {
if (!line.trim()) continue
try {
const obj = JSON.parse(line) as { id?: unknown; result?: unknown; error?: unknown }
if (obj.id !== undefined && (obj.result !== undefined || obj.error !== undefined)) {
return obj as { id: unknown; result?: unknown; error?: unknown }
}
} catch {
// incomplete line, keep accumulating
}
}
return null
}
async function smokeServer(name: string, def: ServerDef): Promise<{
outcome: SpawnOutcome
prereqResults: ProbeResult[]
}> {
const prereqResults: ProbeResult[] = []
for (const p of def.prerequisites ?? []) {
prereqResults.push(await probeCommand(p.command))
}
const missing = prereqResults.filter((r) => !r.installed).map((r) => r.command)
if (missing.length > 0) {
return { prereqResults, outcome: { kind: 'skipped-missing-prereq', missing } }
}
// All declared prereqs present; spawn the server and probe initialize.
const env = { ...process.env, ...(def.env ?? {}) }
let proc: ReturnType<typeof spawn>
try {
proc = spawn(def.command, def.args, {
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
})
} catch (err) {
return {
prereqResults,
outcome: {
kind: 'spawn-failed',
reason: err instanceof Error ? err.message : String(err),
},
}
}
let stderrBuf = ''
proc.stderr?.on('data', (b) => {
stderrBuf += b.toString('utf8')
if (stderrBuf.length > 8192) stderrBuf = stderrBuf.slice(-8192)
})
const start = Date.now()
const initializePromise = new Promise<SpawnOutcome>((resolve) => {
let stdoutBuf = Buffer.alloc(0)
let resolved = false
const finish = (out: SpawnOutcome) => {
if (resolved) return
resolved = true
resolve(out)
}
proc.stdout?.on('data', (b: Buffer) => {
stdoutBuf = Buffer.concat([stdoutBuf, b])
const parsed = parseFramedResponse(stdoutBuf)
if (parsed) {
finish({ kind: 'initialize-ok', ms: Date.now() - start })
} else if (stdoutBuf.length > 32 * 1024) {
finish({
kind: 'parse-failed',
raw: stdoutBuf.toString('utf8', 0, 200),
stderrTail: stderrBuf.slice(-2048),
})
}
})
proc.on('exit', (code) => {
finish({
kind: 'process-exited',
code,
stderrTail: stderrBuf.slice(-2048),
})
})
proc.on('error', () => {
finish({
kind: 'spawn-failed',
reason: 'spawn error event',
})
})
setTimeout(() => {
finish({
kind: 'initialize-no-response',
ms: Date.now() - start,
stderrTail: stderrBuf.slice(-2048),
})
}, 5000) // 5s — some uvx-from-git installs are slow first time
})
// Send initialize after a brief delay to let the process bind stdin.
setTimeout(() => {
try {
const payload = frameJsonRpc({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'cc-haha-smoke', version: '0.5.11' },
},
})
proc.stdin?.write(payload)
} catch {
// ignored — outcome will be no-response or exit
}
}, 100)
const outcome = await initializePromise
try {
proc.kill('SIGKILL')
} catch {
// ignored
}
return { prereqResults, outcome }
}
function describeOutcome(outcome: SpawnOutcome): string {
switch (outcome.kind) {
case 'skipped-missing-prereq':
return `❌ prereq missing: ${outcome.missing.join(', ')}`
case 'spawn-failed':
return `❌ spawn failed: ${outcome.reason}`
case 'initialize-ok':
return `✅ initialize ok (${outcome.ms} ms)`
case 'initialize-no-response':
return `⚠️ spawned but no JSON-RPC response in ${outcome.ms} ms`
case 'process-exited':
return `❌ process exited (code=${outcome.code ?? 'null'})`
case 'parse-failed':
return `❌ unrecognized output (no Content-Length frame)`
}
}
function describeStderr(outcome: SpawnOutcome): string | null {
if ('stderrTail' in outcome && outcome.stderrTail && outcome.stderrTail.trim()) {
return outcome.stderrTail.trim().split(/\r?\n/).slice(-3).join(' | ')
}
return null
}
async function main() {
const pluginIdx = args.indexOf('--plugin')
const pluginName = pluginIdx >= 0 ? args[pluginIdx + 1] : 'reverse-engineering'
const serversPath = path.join(ROOT, 'plugins', pluginName!, 'mcp', 'servers.json')
let raw: string
try {
raw = await readFile(serversPath, 'utf-8')
} catch (err) {
console.error(`Failed to read ${serversPath}: ${err instanceof Error ? err.message : err}`)
process.exit(1)
}
const file = JSON.parse(raw) as ServersFile
const entries = Object.entries(file.mcpServers ?? {})
console.log(`\n=== Reverse-engineering MCP smoke ===`)
console.log(`Source: ${path.relative(ROOT, serversPath)}`)
console.log(`Servers: ${entries.length}`)
console.log('')
const summaries: Array<{
name: string
prereqResults: ProbeResult[]
outcome: SpawnOutcome
}> = []
const platform = platformKey()
const installCommandsByMissingTool = new Map<string, InstallStep[]>()
for (const [name, def] of entries) {
process.stdout.write(` ${name.padEnd(10)}`)
const { outcome, prereqResults } = await smokeServer(name, def)
summaries.push({ name, prereqResults, outcome })
// Collect install commands for missing prereqs (deduped by tool name).
if (outcome.kind === 'skipped-missing-prereq') {
for (const tool of outcome.missing) {
if (installCommandsByMissingTool.has(tool)) continue
const prereq = def.prerequisites?.find((p) => p.command === tool)
if (prereq?.install?.[platform]) {
installCommandsByMissingTool.set(tool, prereq.install[platform]!)
}
}
}
console.log(describeOutcome(outcome))
if (verbose) {
const stderr = describeStderr(outcome)
if (stderr) console.log(` stderr: ${stderr}`)
for (const pr of prereqResults) {
console.log(` prereq ${pr.command.padEnd(10)} ${pr.installed ? '✓' : '✗'} ${pr.resolved ?? ''}`)
}
}
}
// ── Summary ───────────────────────────────────────────────────────────
const ok = summaries.filter((s) => s.outcome.kind === 'initialize-ok')
const noResp = summaries.filter((s) => s.outcome.kind === 'initialize-no-response')
const exited = summaries.filter((s) => s.outcome.kind === 'process-exited')
const skipped = summaries.filter((s) => s.outcome.kind === 'skipped-missing-prereq')
const spawnFail = summaries.filter((s) => s.outcome.kind === 'spawn-failed')
const parseFail = summaries.filter((s) => s.outcome.kind === 'parse-failed')
console.log('')
console.log('=== Summary ===')
console.log(` ✅ initialize ok : ${ok.length}/${entries.length} (${ok.map((s) => s.name).join(', ') || '—'})`)
console.log(` ⚠️ spawned, no response: ${noResp.length}/${entries.length} (${noResp.map((s) => s.name).join(', ') || '—'})`)
console.log(` ❌ process exited : ${exited.length}/${entries.length} (${exited.map((s) => s.name).join(', ') || '—'})`)
console.log(` ❌ prereq missing : ${skipped.length}/${entries.length} (${skipped.map((s) => s.name).join(', ') || '—'})`)
console.log(` ❌ spawn failed : ${spawnFail.length}/${entries.length} (${spawnFail.map((s) => s.name).join(', ') || '—'})`)
console.log(` ❌ parse failed : ${parseFail.length}/${entries.length} (${parseFail.map((s) => s.name).join(', ') || '—'})`)
// ── Action items: install commands for missing prereqs ────────────────
if (installCommandsByMissingTool.size > 0) {
console.log('')
console.log(`=== Install commands for missing prereqs (${platform}) ===`)
for (const [tool, steps] of installCommandsByMissingTool) {
console.log(` ${tool}:`)
for (const step of steps) {
console.log(` ${step.manager.padEnd(10)} ${step.cmd}`)
}
}
console.log('')
console.log('Pick one manager per tool. After installing, rerun this script.')
}
// ── Schema-gap warnings ───────────────────────────────────────────────
// Heuristic: an MCP server whose name implies a tool (jadx, apktool,
// gdb, radare2, lldb) should have that tool listed in its prerequisites
// — otherwise the desktop "one-click install" never knows to install
// it, and the server crashes at first run.
//
// Note: `ghidra` is intentionally excluded — Ghidra is a GUI binary
// configured via the `GHIDRA_INSTALL_DIR` env var, not a PATH command,
// so probing for it via `where ghidra` would always fail.
//
// Note: `frida` is intentionally excluded — the `frida-mcp` PyPI
// package bundles its own Python `frida` client as a transitive
// dependency, so the server starts fine without the standalone
// `frida` CLI on PATH. Connecting to a target device may still need
// a frida-server installed on the target itself, which is out of
// scope for prereq probing.
const expectedToolByServer: Record<string, string> = {
jadx: 'jadx',
apktool: 'apktool',
lldb: 'lldb',
gdb: 'gdb',
radare2: 'radare2',
}
const schemaGaps: string[] = []
for (const [name, def] of entries) {
const expected = expectedToolByServer[name]
if (!expected) continue
const declared = (def.prerequisites ?? []).some((p) => p.command === expected)
if (!declared) {
schemaGaps.push(`${name}: prerequisites missing '${expected}' — desktop one-click install can't surface this missing tool`)
}
}
if (schemaGaps.length > 0) {
console.log('')
console.log('=== Schema gaps (servers.json fix candidates) ===')
for (const g of schemaGaps) console.log(g)
}
// ── Exit code ─────────────────────────────────────────────────────────
// Exit 0 when at least one server initialized — the script is informational,
// not a CI gate. CI consumers can grep stdout if they want a strict mode.
process.exit(0)
}
void main()