diff --git a/README.md b/README.md index 0b5e057e1a..61357610c4 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,70 @@ -# betterBinaryMCP +# Better Binary -A TypeScript MCP server for [Hopper Disassembler](https://www.hopperapp.com/). It preserves 31 Hopper operations and adds 8 reverse-engineering workflows for Swift, Objective-C, decompilation, call graphs, cross-references, and binary summaries. +Agent-ready Hopper Disassembler tooling: a beta.3 MCP server with 31 official proxies, 8 enhanced reverse-engineering workflows, and 3 binary-session tools. -## Prerequisites +## Install from this checkout -Check these before you start: - -- **Node.js 20+** (`node --version`) -- **Hopper Disassembler 6+** on macOS, at `/Applications/Hopper Disassembler.app/` -- **A binary to analyze** (Mach-O executable, `.dylib`, `.hop` database, etc.) - -Accessibility permission is not required. - -## Quick Start +Requires macOS 12+ and Node.js 22+. ```bash -git clone https://github.com/morluto/betterBinaryMCP.git && cd betterBinaryMCP -npm ci && npm run build +npm ci +npm run build +npm link +better-binary setup --yes ``` -Generate an MCP config with absolute paths filled in: +`setup` is safe to repeat. It checks the host, installs Homebrew when approved and absent, installs Hopper from the official `hopper-disassembler` cask, and finishes with structured diagnostics. It never bundles Hopper or publishes this private package. + +Useful onboarding commands: ```bash -npm run config:print -- /path/to/your/binary +better-binary --help +better-binary --llms +better-binary doctor --target /path/to/binary +better-binary skills add +better-binary mcp add ``` -Paste the output into your client's MCP config file: +## MCP workflow -| Client | Config path | -| ----------------- | ------------------------------------------------------------------------- | -| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) | -| Cursor | `.cursor/mcp.json` in your project root | -| Other MCP clients | Check the client's docs for MCP server config | +Start `better-binary-mcp` with no target. In the connected agent: -Restart the client. The 39 tools appear in the tool list. +1. Call `open_binary` with any readable local binary or `.hop` path. +2. Call `binary_overview`, then use decompilation, strings, symbols, and xrefs tools. +3. Call `open_binary` again to switch targets; a failed switch preserves the current target. +4. Call `close_binary` when finished. `binary_session` reports current state. -```bash -# For a .hop database: -npm run config:print -- /path/to/file.hop --kind database +Relative target paths resolve against the MCP server's working directory. Mach-O/FAT, ELF, PE, and Hopper databases are detected automatically. FAT loading selects the host-compatible architecture. -# For a FAT binary that needs loader args: -npm run config:print -- /path/to/binary --loader-args '["-l","Mach-O","--aarch64"]' -``` +`HOPPER_TARGET_PATH` remains supported for older configurations but is optional. `HOPPER_LAUNCHER_PATH`, `HOPPER_TARGET_KIND`, and `HOPPER_LOADER_ARGS_JSON` retain their existing meanings for legacy startup configuration. -### What happens on first tool call - -The server does not launch Hopper at startup. Hopper launches lazily on the first tool call that needs it, which means the first call can take 10-30 seconds while Hopper opens and analyzes the binary. Subsequent calls are fast. If the first call times out, see [Troubleshooting](#troubleshooting). - -### Try it - -Once connected, call `binary_overview` with no arguments. It returns segment layout, procedure count, and string count. If you get JSON back, the server is working. - -### Set up with your agent - -Paste this prompt into Claude, Cursor, or any MCP-capable agent to have it do the setup for you: - -> I want to connect betterBinaryMCP (a Hopper Disassembler MCP server) to this client. -> -> 1. Clone `https://github.com/morluto/betterBinaryMCP.git` and run `npm ci && npm run build`. -> 2. Run `npm run config:print -- /path/to/my/binary` to generate the MCP server config. -> 3. Paste the output into this client's MCP config and restart. -> 4. Call `binary_overview` with no arguments to verify the server is working. - -## Architecture - -```text -MCP client ← stdio → TypeScript MCP server ← Unix socket → owned Python bridge → Hopper.app -``` - -The codebase is a layered ESM TypeScript application. Dependencies flow inward: `domain` ← `contracts` ← `hopper` ← `application` ← `server` ← `main`. - -- `src/main.ts` is the composition root: it parses config, wires the Hopper client, starts the stdio MCP transport, and owns process-lifetime shutdown. -- `src/hopper/` lazily launches Hopper on the first Hopper-dependent tool and correlates bounded bridge messages, so lengthy analysis does not block the MCP handshake. -- `bridge/hopper_bridge.py` runs inside Hopper and calls its public Python API. Hopper's bundled MCP server is not executed or required. -- `src/application/` composes official operations into the 8 enhanced tools. -- `src/domain/` holds pure parsers and the tagged error algebra; `src/contracts/` declares the caller-visible tool schemas. - -## Manual MCP Configuration - -If you prefer to write the config by hand, build the project and use absolute paths: +Manual target-free MCP configuration: ```json { "mcpServers": { - "betterBinaryMCP": { - "command": "node", - "args": ["/absolute/path/to/betterBinaryMCP/dist/main.js"], - "env": { - "HOPPER_TARGET_PATH": "/absolute/path/to/binary" - } + "better-binary": { + "command": "better-binary-mcp" } } } ``` -Stdout is reserved for MCP protocol messages; diagnostics use stderr. +The first `open_binary` can take time while Hopper analyzes the file. Analysis calls made without an open target return an actionable `NoBinaryOpenError`. -### Environment Variables - -| Variable | Required | Default | Description | -| ------------------------- | -------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- | -| `HOPPER_TARGET_PATH` | yes | — | Absolute path to the binary or `.hop` database to analyze. | -| `HOPPER_LAUNCHER_PATH` | no | `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` | Override the Hopper executable path. | -| `HOPPER_TARGET_KIND` | no | `executable` | `executable` or `database` (for `.hop` files). | -| `HOPPER_LOADER_ARGS_JSON` | no | `[]` | JSON array of strings passed to the Hopper launcher before the target. | - -For an Apple FAT binary, for example: - -```json -["-l", "FAT", "--aarch64", "-l", "Mach-O"] -``` - -## Tools - -### 31 Official Tools (proxied) - -| Tool | Description | -| --------------------------------------------------------------------------------------------------------- | ---------------------------------- | -| `address_name` | Get the name (label) at an address | -| `comment` / `inline_comment` | Get comments at an address | -| `current_address` / `current_procedure` / `current_document` | Cursor state | -| `goto_address` / `next_address` / `prev_address` | Navigation | -| `list_segments` / `list_procedures` / `list_strings` / `list_names` / `list_documents` / `list_bookmarks` | Listing | -| `search_procedures` / `search_strings` | Regex search | -| `procedure_pseudo_code` / `procedure_assembly` | Decompilation | -| `procedure_callees` / `procedure_callers` | Call relationships | -| `procedure_info` / `procedure_address` | Procedure metadata | -| `set_address_name` / `set_addresses_names` | Rename labels | -| `set_comment` / `set_inline_comment` | Write comments | -| `set_bookmark` / `unset_bookmark` | Bookmarks | -| `set_current_document` | Switch active document | -| `xrefs` | Cross-references | - -### 8 Enhanced Tools - -| Tool | Parameters | Purpose | -| --------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------- | -| `binary_overview` | — | Summarize documents, segments, procedures, and strings | -| `swift_classes` | `pattern?: string` | Find Swift `_TtC` class symbols | -| `get_objc_classes` | `pattern?: string` | Find Objective-C class labels | -| `get_objc_protocols` | — | Find Objective-C and Swift protocol labels | -| `batch_decompile` | `addresses: string[]` (max 20) | Decompile up to 20 procedures concurrently | -| `get_call_graph` | `address: string`, `direction?: "forward"\|"backward"`, `depth?: 1–5` | Traverse callers or callees to a bounded depth | -| `analyze_swift_types` | — | Categorize Swift classes, structs, enums, protocols, and extensions | -| `find_xrefs_to_name` | `name: string` | Resolve a name and retrieve cross-references | - -## Development +## Development and verification ```bash -npm run typecheck -npm run lint -npm run format:check -npm test npm run check -``` - -`npm test` builds first. Tests cover all 39 contracts, every handler through a beta.3 MCP client, bridge failures, concurrency, strict boundary parsing, and the compiled stdio runtime. - -Run the real-Hopper verifier with a representative binary and any required loader options: - -```bash -HOPPER_TARGET_PATH=/path/to/binary \ -HOPPER_LOADER_ARGS_JSON='["-l","Mach-O","--aarch64"]' \ +npm pack --dry-run +HOPPER_TARGET_PATH=/path/to/target-a \ +HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \ npm run verify:hopper ``` -It asserts the exact 39-tool inventory, document access, `binary_overview`, segment reads, a one-procedure bounded decompile, clean protocol output (no stderr), absence of Hopper's bundled MCP process, and clean shutdown (no leaked bridge session directories or lingering processes). +Real-Hopper verification requires Hopper and a representative target. The stdio server uses only the repository's authenticated Unix-socket bridge; Hopper's bundled MCP server is not used. -## Troubleshooting - -If startup times out, verify the target path and reproduce the generated loader chain with Hopper's `hopper` launcher. A visible loader or save dialog prevents the post-analysis bridge from starting; supply explicit loader/CPU options or close the stale dialog. Granting Accessibility access will not fix this. - -`batch_decompile` accepts at most 20 addresses, and `get_call_graph` accepts depths from 1 through 5. +The provisional package, executable, MCP, skill, and config names are centralized in `src/identity.ts` so release naming can change cleanly. The package remains private and is not ready for publication. ## License diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index c56a95e1db..f823a38b85 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readdir } from "node:fs/promises"; +import { readdir, realpath } from "node:fs/promises"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; @@ -21,15 +21,27 @@ const textValue = (result) => { const jsonValue = (result) => JSON.parse(textValue(result)); -if (!process.env.HOPPER_TARGET_PATH) { - throw new Error("HOPPER_TARGET_PATH is required"); -} +const targetPath = process.env.HOPPER_TARGET_PATH; +const secondTargetPath = process.env.HOPPER_SECOND_TARGET_PATH; +if (!targetPath || !secondTargetPath) + throw new Error( + "HOPPER_TARGET_PATH and HOPPER_SECOND_TARGET_PATH are required and must name distinct binaries", + ); +const [targetA, targetB] = await Promise.all([ + realpath(targetPath), + realpath(secondTargetPath), +]); +if (targetA === targetB) + throw new Error("Real-Hopper verification requires two distinct targets"); +const serverEnvironment = { ...process.env }; +delete serverEnvironment.HOPPER_TARGET_PATH; +delete serverEnvironment.HOPPER_SECOND_TARGET_PATH; const transport = new StdioClientTransport({ command: process.execPath, args: ["dist/main.js"], cwd: process.cwd(), - env: process.env, + env: serverEnvironment, stderr: "pipe", }); let stderrBytes = 0; @@ -45,10 +57,21 @@ try { const expectedNames = TOOL_CONTRACTS.map(({ name }) => name).sort(); const actualNames = listed.tools.map(({ name }) => name).sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { - throw new Error("The real server did not expose the intended 39 tools"); + throw new Error("The real server did not expose the intended 42 tools"); } const options = { timeout }; + const initialSession = await client.callTool( + { name: "binary_session", arguments: {} }, + options, + ); + if (jsonValue(initialSession).open !== false) + throw new Error("The verifier did not start without a target"); + const opened = await client.callTool( + { name: "open_binary", arguments: { path: targetA } }, + options, + ); + if (opened.isError === true) throw new Error(textValue(opened)); const documents = await client.callTool( { name: "list_documents", arguments: {} }, options, @@ -74,6 +97,22 @@ try { { name: "batch_decompile", arguments: { addresses: [firstAddress] } }, options, ); + const switched = await client.callTool( + { name: "open_binary", arguments: { path: targetB } }, + options, + ); + if (switched.isError === true) throw new Error(textValue(switched)); + const secondSession = jsonValue( + await client.callTool({ name: "binary_session", arguments: {} }, options), + ); + if (secondSession.path !== targetB) + throw new Error("The real session did not switch to target B"); + const secondOverview = await client.callTool( + { name: "binary_overview", arguments: {} }, + options, + ); + if (secondOverview.isError === true) + throw new Error(textValue(secondOverview)); const processList = await execFileAsync("ps", ["-ax", "-o", "command="]); const bundledMcpRunning = processList.stdout @@ -88,6 +127,12 @@ try { throw new Error("The MCP runtime emitted stderr during verification"); } + const closed = await client.callTool( + { name: "close_binary", arguments: {} }, + options, + ); + if (closed.isError === true) throw new Error(textValue(closed)); + summary = { toolCount: actualNames.length, documentCount: jsonValue(documents).length, @@ -98,6 +143,10 @@ try { boundedResultKeys: Object.keys(jsonValue(bounded)).length, bundledMcpRunning, stderrBytes, + dynamicSession: true, + targets: [targetA, targetB], + switched: true, + secondOverview: jsonValue(secondOverview), }; } finally { const keepAlive = setInterval(() => undefined, 100); @@ -130,6 +179,14 @@ if ( "The TypeScript MCP server remained alive after client close", ); } -process.stdout.write( - `${JSON.stringify({ ...summary, cleanShutdown: true }, null, 2)}\n`, -); +if (summary === undefined) + throw new Error("Real-Hopper verification did not produce a summary"); +await new Promise((resolve, reject) => { + process.stdout.write( + `${JSON.stringify({ ...summary, cleanShutdown: true }, null, 2)}\n`, + (cause) => { + if (cause) reject(cause); + else resolve(); + }, + ); +});