Merge pull request #1 from morluto/morl/binaryimprovement

Add dynamic binary sessions and guided onboarding
This commit is contained in:
morluto authored and GitHub committed 2026-07-12 07:01:05 +08:00
commit fcf4e308b2
35 files changed
+2275 -228

No files matched your search

+34 -141
View File
@@ -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
+5 -7
View File
@@ -132,19 +132,13 @@ def _dispatch(method, params):
return _selected_document
document = _document(params.get("document"))
address = _address(document, params.get("address")) if method not in (
"list_segments", "list_procedures", "list_strings", "list_names",
"search_procedures", "search_strings", "procedure_address",
"procedure_info", "procedure_assembly", "procedure_pseudo_code",
"procedure_callers", "procedure_callees", "set_addresses_names",
"list_bookmarks", "current_address", "current_procedure"
) else None
if method == "current_address":
return _hex(document.getCurrentAddress())
if method == "current_procedure":
return _procedure_name(_procedure(document))
if method == "goto_address":
address = _address(document, params.get("address"))
document.moveCursorAtAddress(address)
return _hex(address)
if method in ("address_name", "comment", "inline_comment", "xrefs"):
@@ -208,10 +202,12 @@ def _dispatch(method, params):
length = sum(max(0, block.getEndingAddress() - block.getStartingAddress()) for block in blocks)
return {"name": _procedure_name(procedure), "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, "signature": procedure.signatureString(), "locals": procedure.getLocalVariableList()}
if method == "set_address_name":
address = _address(document, params.get("address"))
return document.setNameAtAddress(address, params["name"])
if method == "set_addresses_names":
return {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()}
if method in ("set_comment", "set_inline_comment"):
address = _address(document, params.get("address"))
segment = _segment(document, address)
setter = segment.setCommentAtAddress if method == "set_comment" else segment.setInlineCommentAtAddress
getter = segment.getCommentAtAddress if method == "set_comment" else segment.getInlineCommentAtAddress
@@ -220,9 +216,11 @@ def _dispatch(method, params):
if method == "list_bookmarks":
return [{"address": _hex(item), "name": document.getBookmarkName(item)} for item in document.getBookmarks()]
if method == "set_bookmark":
address = _address(document, params.get("address"))
document.setBookmarkAtAddress(address, params.get("name"))
return document.hasBookmarkAtAddress(address)
if method == "unset_bookmark":
address = _address(document, params.get("address"))
document.removeBookmarkAtAddress(address)
return not document.hasBookmarkAtAddress(address)
raise ValueError("Unknown bridge method")
+65
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@modelcontextprotocol/server": "2.0.0-beta.3",
"incur": "0.4.13",
"zod": "4.4.3"
},
"bin": {
@@ -28,6 +29,12 @@
"node": ">=20"
}
},
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -602,6 +609,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@scalar/openapi-types": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.8.0.tgz",
"integrity": "sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g==",
"license": "MIT",
"engines": {
"node": ">=22"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -609,6 +625,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@toon-format/toon": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.0.tgz",
"integrity": "sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -1646,6 +1668,28 @@
"node": ">=0.8.19"
}
},
"node_modules/incur": {
"version": "0.4.13",
"resolved": "https://registry.npmjs.org/incur/-/incur-0.4.13.tgz",
"integrity": "sha512-BeKlYFLIsRCgC8IxsUd3S2/4kPeZaNf1Rbz+Kz41jQII4jOgr7j5w4KYe00aAEQa3V0Ur57BVxE5JDTx8L2s5Q==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@modelcontextprotocol/server": "^2.0.0-alpha.2",
"@scalar/openapi-types": "^0.8.0",
"@toon-format/toon": "^2.1.0",
"tokenx": "^1.3.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"bin": {
"incur": "dist/bin.js",
"incur.src": "src/bin.ts"
},
"engines": {
"node": ">=22"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2474,6 +2518,12 @@
"node": ">=14.0.0"
}
},
"node_modules/tokenx": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tokenx/-/tokenx-1.3.0.tgz",
"integrity": "sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -2774,6 +2824,21 @@
"node": ">=0.10.0"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+12 -1
View File
@@ -4,11 +4,20 @@
"private": true,
"type": "module",
"bin": {
"better-binary": "./scripts/better-binary.mjs",
"better-binary-mcp": "./dist/main.js"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"files": [
"dist",
"bridge",
"scripts/better-binary.mjs",
"skills",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"check": "npm run typecheck && npm run lint && npm run format:check && npm test",
@@ -21,10 +30,12 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
"verify:package": "npm run build && node scripts/verify-package.mjs",
"config:print": "node scripts/print-mcp-config.mjs"
},
"dependencies": {
"@modelcontextprotocol/server": "2.0.0-beta.3",
"incur": "0.4.13",
"zod": "4.4.3"
},
"devDependencies": {
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
import { Cli, z } from "incur";
import { runDoctor } from "../dist/application/Doctor.js";
import { runDirectAnalysis } from "../dist/application/DirectAnalysis.js";
import { runSetup } from "../dist/application/Setup.js";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
version: "0.1.0",
description: "Install, diagnose, and automate Hopper binary analysis.",
mcp: {
command: PRODUCT_IDENTITY.mcpBinary,
instructions:
"Open a local target with open_binary, analyze it, and close the session when finished.",
},
sync: {
cwd: import.meta.dirname,
include: ["../skills/*"],
suggestions: [
"open a binary and summarize it",
"decompile a procedure",
"diagnose my Hopper setup",
],
},
});
cli.command("setup", {
description: "Install prerequisites and configure binary analysis",
options: z.object({
yes: z
.boolean()
.default(false)
.describe("Approve prerequisite installation"),
}),
alias: { yes: "y" },
run: ({ options }) => runSetup(options.yes),
});
cli.command("doctor", {
description: "Diagnose the local Hopper integration",
options: z.object({
target: z.string().optional().describe("Optional binary path to check"),
}),
run: ({ options }) => runDoctor(options.target),
});
cli.command("analyze", {
description: "Analyze a binary through the beta.3 MCP server",
args: z.object({
path: z.string().describe("Binary or Hopper database path"),
}),
run: ({ args }) => runDirectAnalysis(args.path, "binary_overview", {}),
});
cli.command("decompile", {
description: "Decompile an address through the beta.3 MCP server",
args: z.object({
path: z.string().describe("Binary path"),
address: z.string().describe("Procedure address"),
}),
run: ({ args }) =>
runDirectAnalysis(args.path, "procedure_pseudo_code", {
procedure: args.address,
}),
});
cli.serve();
+178
View File
@@ -0,0 +1,178 @@
import { execFile } from "node:child_process";
import {
chmod,
mkdtemp,
mkdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const exec = promisify(execFile);
const root = process.cwd();
const workspace = await mkdtemp(join(tmpdir(), "better-binary-package-"));
let tarball;
try {
tarball = (
await exec("npm", ["pack", "--silent"], { cwd: root })
).stdout.trim();
const prefix = join(workspace, "prefix");
const home = join(workspace, "home");
const fakeBin = join(workspace, "bin");
await mkdir(fakeBin, { recursive: true });
const brew = join(fakeBin, "brew");
const swVers = join(fakeBin, "sw_vers");
const npx = join(fakeBin, "npx");
const npxLog = join(workspace, "npx.log");
const hopper = join(workspace, "hopper");
await writeFile(
brew,
'#!/bin/sh\nif [ "$1" = "--prefix" ]; then echo /fake/cask; fi\nexit 0\n',
);
await writeFile(swVers, "#!/bin/sh\necho 14.5\n");
await writeFile(
npx,
'#!/bin/sh\nprintf "%s\\n" "$*" > "$BETTER_BINARY_NPX_LOG"\necho "│ ✓ Cursor: isolated │"\n',
);
await writeFile(hopper, "#!/bin/sh\nexit 0\n");
await Promise.all([
chmod(brew, 0o755),
chmod(swVers, 0o755),
chmod(hopper, 0o755),
chmod(npx, 0o755),
]);
const claudeDir = join(home, "Library/Application Support/Claude");
const cursorDir = join(home, ".cursor");
await Promise.all([
mkdir(claudeDir, { recursive: true }),
mkdir(cursorDir, { recursive: true }),
]);
const claudeConfig = join(claudeDir, "claude_desktop_config.json");
const cursorConfig = join(cursorDir, "mcp.json");
await writeFile(claudeConfig, '{"existing":true}\n');
await writeFile(cursorConfig, '{"existing":true}\n');
const environment = {
...process.env,
HOME: home,
XDG_CONFIG_HOME: join(home, ".config"),
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
HOPPER_LAUNCHER_PATH: hopper,
BETTER_BINARY_NPX_LOG: npxLog,
};
await exec(
"npm",
["install", "--global", "--prefix", prefix, join(root, tarball)],
{ env: environment },
);
const cli = join(prefix, "bin", "better-binary");
const mcp = join(prefix, "bin", "better-binary-mcp");
const help = await run(cli, ["--help"], environment);
const llms = await run(cli, ["--llms"], environment);
const doctor = json(await run(cli, ["doctor", "--json"], environment));
if (
!help.includes("setup") ||
!llms.includes("decompile") ||
doctor.healthy !== true
)
throw new Error("packaged CLI discovery or doctor failed");
await run(cli, ["mcp", "add"], environment);
const mcpRegistration = await readFile(npxLog, "utf8");
if (
!mcpRegistration.includes("add-mcp better-binary-mcp --name better-binary")
)
throw new Error("Incur mcp add did not register the beta.3 executable");
const first = json(await run(cli, ["setup", "--yes", "--json"], environment));
const second = json(
await run(cli, ["setup", "--yes", "--json"], environment),
);
if (
first.status !== "ready" ||
second.status !== "ready" ||
second.actions.length !== 0
)
throw new Error("packaged setup was not ready and idempotent");
for (const configPath of [claudeConfig, cursorConfig]) {
const config = json(await readFile(configPath, "utf8"));
if (
config.existing !== true ||
config.mcpServers?.["better-binary"]?.command !== "better-binary-mcp"
)
throw new Error("packaged client readback failed");
if (
!(await readFile(`${configPath}.better-binary.backup`, "utf8")).includes(
"existing",
)
)
throw new Error("packaged client backup failed");
}
const skill = await readFile(
join(home, ".agents/skills/better-binary-analysis/SKILL.md"),
"utf8",
);
if (!skill.includes("open_binary"))
throw new Error("packaged skill readback failed");
await writeFile(cursorConfig, "malformed");
const failed = json(
await run(cli, ["setup", "--yes", "--json"], environment),
);
if (
failed.status !== "needs_human" ||
(await readFile(cursorConfig, "utf8")) !== "malformed"
)
throw new Error("packaged setup failure recovery did not preserve input");
await writeFile(cursorConfig, "{}\n");
const recovered = json(
await run(cli, ["setup", "--yes", "--json"], environment),
);
if (recovered.status !== "ready")
throw new Error("packaged setup did not recover");
const transport = new StdioClientTransport({
command: mcp,
env: environment,
stderr: "pipe",
});
let mcpStderr = "";
transport.stderr?.on("data", (chunk) => {
mcpStderr += chunk.toString();
});
const client = new Client({ name: "package-smoke", version: "1.0.0" });
try {
await client.connect(transport);
if ((await client.listTools()).tools.length !== 42)
throw new Error("packaged MCP did not expose 42 tools");
const result = await client.callTool({
name: "current_document",
arguments: {},
});
if (result.isError !== true)
throw new Error("packaged target-free MCP omitted no-target error");
} catch (cause) {
throw new Error(`packaged MCP smoke failed: ${mcpStderr}`, { cause });
} finally {
await client.close();
await transport.close();
}
process.stdout.write(
`${JSON.stringify({ cli: true, incurMcpCommand: "better-binary-mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 42, targetFree: true })}\n`,
);
} finally {
if (tarball) await rm(join(root, tarball), { force: true });
await rm(workspace, { recursive: true, force: true });
}
async function run(command, args, env) {
return (await exec(command, args, { env })).stdout;
}
function json(text) {
return JSON.parse(text);
}
+66 -9
View File
@@ -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();
},
);
});
+8
View File
@@ -0,0 +1,8 @@
---
name: better-binary-analysis
description: Analyze local binaries with Better Binary and Hopper through its MCP session workflow.
---
# Better Binary Analysis
Call `open_binary` with a readable local path before analysis. Begin with `binary_overview`, use strings and symbol searches to identify relevant procedures, then use `procedure_pseudo_code`, callers, callees, and xrefs to build evidence. Call `open_binary` again to switch targets and `close_binary` when finished. Never claim real-Hopper behavior from mock-only evidence.
+146
View File
@@ -0,0 +1,146 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import {
HopperCancelledError,
NoBinaryOpenError,
type HopperError,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { JsonValue } from "../hopper/protocol.js";
import type { HopperToolPort } from "./HopperToolPort.js";
/** A closeable Hopper port created for exactly one parsed target. */
export interface BinaryClient extends HopperToolPort {
close(): Promise<void>;
}
/** Production seam for creating a target-scoped Hopper client. */
export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
/** Serializes target transitions while allowing calls within the active session. */
export class BinarySession implements HopperToolPort {
#active:
| { readonly target: BinaryTarget; readonly client: BinaryClient }
| undefined;
#transition: Promise<void> = Promise.resolve();
readonly #calls = new Set<Promise<unknown>>();
constructor(readonly createClient: BinaryClientFactory) {}
/** Open or switch to a target, retaining the old session when the new one fails. */
open(
path: string,
options: { readonly signal?: AbortSignal } = {},
): Promise<Result<BinaryTarget, HopperError>> {
return this.#serialize(async () => {
if (isAborted(options.signal)) return err(new HopperCancelledError());
const parsed = await parseBinaryTarget(path);
if (!parsed.ok) return parsed;
if (isAborted(options.signal)) return err(new HopperCancelledError());
if (this.#active?.target.path === parsed.value.path)
return ok(parsed.value);
await this.#drainCalls();
const previous = this.#active;
const client = this.createClient(parsed.value);
const started = await client.callTool("health", {}, options);
if (!started.ok) {
await client.close();
return started;
}
if (isAborted(options.signal)) {
await client.close();
return err(new HopperCancelledError());
}
this.#active = { target: parsed.value, client };
await previous?.client.close();
return ok(parsed.value);
});
}
/** Close the active target, if any. */
close(): Promise<Result<null, HopperError>> {
return this.#serialize(async () => {
const previous = this.#active;
this.#active = undefined;
await this.#drainCalls();
await previous?.client.close();
return ok(null);
});
}
/** Describe the active binary session. */
status(): JsonValue {
const target = this.#active?.target;
return target === undefined
? { open: false }
: {
open: true,
path: target.path,
format: target.format,
kind: target.kind,
};
}
/** Invoke a Hopper tool against the active target. */
async callTool(
name: string,
arguments_: Readonly<Record<string, JsonValue>>,
options?: { readonly signal?: AbortSignal },
): Promise<Result<JsonValue, HopperError>> {
const transitioned = await this.#waitForTransition(options?.signal);
if (!transitioned.ok) return transitioned;
const active = this.#active;
if (active === undefined) return err(new NoBinaryOpenError());
const call = active.client.callTool(name, arguments_, options);
this.#calls.add(call);
try {
return await call;
} finally {
this.#calls.delete(call);
}
}
#serialize<T>(operation: () => Promise<T>): Promise<T> {
const result = this.#transition.then(operation, operation);
this.#transition = result.then(
() => undefined,
() => undefined,
);
return result;
}
async #drainCalls(): Promise<void> {
await Promise.allSettled([...this.#calls]);
}
async #waitForTransition(
signal?: AbortSignal,
): Promise<Result<undefined, HopperCancelledError>> {
if (signal?.aborted === true) return err(new HopperCancelledError());
if (signal === undefined) {
await this.#transition;
return ok(undefined);
}
return new Promise((resolve) => {
const onAbort = (): void => {
resolve(err(new HopperCancelledError()));
};
signal.addEventListener("abort", onAbort, { once: true });
this.#transition.then(
() => {
signal.removeEventListener("abort", onAbort);
resolve(
signal.aborted ? err(new HopperCancelledError()) : ok(undefined),
);
},
() => {
signal.removeEventListener("abort", onAbort);
resolve(
signal.aborted ? err(new HopperCancelledError()) : ok(undefined),
);
},
);
});
}
}
const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
+47
View File
@@ -0,0 +1,47 @@
import { fileURLToPath } from "node:url";
import { parseConfig } from "../config.js";
import { HopperApplicationLauncher } from "../hopper/BridgeLauncher.js";
import { HopperClient } from "../hopper/HopperClient.js";
import type { JsonValue } from "../hopper/protocol.js";
import { BinarySession } from "./BinarySession.js";
/** Open one binary, execute one tool, and always release the target session. */
export const runDirectAnalysis = async (
path: string,
tool: "binary_overview" | "procedure_pseudo_code",
arguments_: Readonly<Record<string, JsonValue>>,
): Promise<JsonValue> => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const bridgeScriptPath = fileURLToPath(
new URL("../../bridge/hopper_bridge.py", import.meta.url),
);
const session = new BinarySession(
(target) =>
new HopperClient({
launcher: new HopperApplicationLauncher({
launcherPath: config.value.hopperLauncherPath,
targetPath: target.path,
targetKind: target.kind,
loaderArgs:
config.value.hopperLoaderArgs.length > 0
? config.value.hopperLoaderArgs
: target.loaderArgs,
bridgeScriptPath,
}),
}),
);
try {
const opened = await session.open(path);
if (!opened.ok)
return { error: opened.error._tag, message: opened.error.message };
const result = await session.callTool(tool, arguments_);
return result.ok
? result.value
: { error: result.error._tag, message: result.error.message };
} finally {
await session.close();
}
};
+168
View File
@@ -0,0 +1,168 @@
import { constants } from "node:fs";
import { access } from "node:fs/promises";
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { probeHomebrew } from "./homebrew.js";
const execFileAsync = promisify(execFile);
const DEFAULT_HOPPER =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
/** One actionable environment diagnostic. */
export interface DoctorCheck {
readonly name: string;
readonly ok: boolean;
readonly detail?: string;
readonly remediation?: string;
}
/** Host facts and effects required by diagnostics. */
export interface DoctorHost {
readonly platform: NodeJS.Platform;
readonly nodeVersion: string;
readonly configuredHopperPath?: string;
macosVersion(): Promise<string | undefined>;
readable(path: string): Promise<boolean>;
brewHopperPath(): Promise<string | undefined>;
manualHopperPaths(): Promise<readonly string[]>;
}
/** Check requirements and an optional target without mutating the host. */
export const runDoctor = async (
target?: string,
host: DoctorHost = systemDoctorHost(),
): Promise<{
readonly healthy: boolean;
readonly hopperPath?: string;
readonly checks: readonly DoctorCheck[];
}> => {
const checks: DoctorCheck[] = [];
const nodeMajor = parseMajor(host.nodeVersion);
checks.push(
check(
"node",
nodeMajor >= 22,
host.nodeVersion,
"Install Node.js 22 or newer.",
),
);
const macosVersion =
host.platform === "darwin" ? await host.macosVersion() : undefined;
const macosMajor = macosVersion === undefined ? 0 : parseMajor(macosVersion);
checks.push(
check(
"macos",
host.platform === "darwin" && macosMajor >= 12,
macosVersion ?? host.platform,
"Hopper requires macOS 12 or newer.",
),
);
const candidates = [
host.configuredHopperPath,
DEFAULT_HOPPER,
...(await host.manualHopperPaths()),
await host.brewHopperPath(),
].filter((value): value is string => value !== undefined);
let hopperPath: string | undefined;
for (const candidate of new Set(candidates))
if (await host.readable(candidate)) {
hopperPath = candidate;
break;
}
checks.push(
check(
"hopper",
hopperPath !== undefined,
hopperPath,
"Install Hopper with: brew install --cask hopper-disassembler, or set HOPPER_LAUNCHER_PATH.",
),
);
if (target !== undefined)
checks.push(
check(
"target",
await host.readable(target),
target,
"Supply a readable local binary path.",
),
);
return {
healthy: checks.every(({ ok }) => ok),
...(hopperPath === undefined ? {} : { hopperPath }),
checks,
};
};
/** Create diagnostics backed by the current process and host commands. */
export const systemDoctorHost = (): DoctorHost => ({
platform: process.platform,
nodeVersion: process.versions.node,
...(process.env.HOPPER_LAUNCHER_PATH === undefined
? {}
: { configuredHopperPath: process.env.HOPPER_LAUNCHER_PATH }),
async macosVersion() {
try {
return (
await execFileAsync("sw_vers", ["-productVersion"])
).stdout.trim();
} catch {
return undefined;
}
},
async readable(path) {
try {
await access(path, constants.R_OK);
return true;
} catch {
return false;
}
},
async brewHopperPath() {
return probeHomebrew(async (command) => {
try {
const prefix = (
await execFileAsync(command, [
"--prefix",
"--cask",
"hopper-disassembler",
])
).stdout.trim();
return `${prefix}/Hopper Disassembler.app/Contents/MacOS/hopper`;
} catch {
return undefined;
}
});
},
async manualHopperPaths() {
const roots = ["/Applications", join(homedir(), "Applications")];
const paths: string[] = [];
for (const root of roots) {
try {
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name))
paths.push(join(root, entry.name, "Contents/MacOS/hopper"));
}
} catch {
// A missing or unreadable optional application directory is not fatal.
}
}
return paths;
},
});
const parseMajor = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const check = (
name: string,
ok: boolean,
detail: string | undefined,
remediation: string,
): DoctorCheck => ({
name,
ok,
...(detail === undefined ? {} : { detail }),
...(ok ? {} : { remediation }),
});
+5 -8
View File
@@ -131,7 +131,7 @@ export class EnhancedTools {
const relation = input.direction === "forward" ? "callees" : "callers";
const tool =
input.direction === "forward" ? "procedure_callees" : "procedure_callers";
const visited = new Set<string>();
const discovered = new Set([input.address]);
const queue: Array<{ address: string; depth: number }> = [
{ address: input.address, depth: 0 },
];
@@ -140,14 +140,9 @@ export class EnhancedTools {
while (queueIndex < queue.length) {
const current = queue[queueIndex++];
if (
current === undefined ||
visited.has(current.address) ||
current.depth >= input.depth
) {
if (current === undefined || current.depth >= input.depth) {
continue;
}
visited.add(current.address);
const level = String(current.depth);
graph[level] ??= [];
@@ -177,8 +172,10 @@ export class EnhancedTools {
});
if (current.depth + 1 < input.depth) {
for (const address of related.value) {
if (!visited.has(address))
if (!discovered.has(address)) {
discovered.add(address);
queue.push({ address, depth: current.depth + 1 });
}
}
}
}
+309
View File
@@ -0,0 +1,309 @@
import {
access,
copyFile,
mkdir,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { runDoctor, systemDoctorHost } from "./Doctor.js";
import { probeHomebrew } from "./homebrew.js";
const execFileAsync = promisify(execFile);
/** A detected agent configuration owned by setup. */
export interface SetupClient {
readonly name: string;
readonly configPath: string;
}
/** Result of one backup/write/readback transaction. */
export type ClientConfigurationResult =
| {
readonly status: "unchanged" | "configured";
readonly backupPath?: string;
}
| {
readonly status: "failed";
readonly reason: "backup" | "write" | "readback";
};
/** Effects required by the idempotent setup workflow. */
export interface SetupHost {
readonly platform: NodeJS.Platform;
readonly nodeVersion: string;
macosVersion(): Promise<string | undefined>;
hasHomebrew(): Promise<boolean>;
installHomebrew(): Promise<boolean>;
hopperPath(): Promise<string | undefined>;
installHopper(): Promise<boolean>;
detectedClients(): Promise<readonly SetupClient[]>;
configureClient(client: SetupClient): Promise<ClientConfigurationResult>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
}
/** Structured setup outcome with actionable partial progress. */
export interface SetupResult {
readonly status: "ready" | "needs_human";
readonly actions: readonly string[];
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
readonly doctor: Awaited<ReturnType<typeof runDoctor>>;
readonly remediation?: string;
}
/** Install prerequisites and configure every detected supported client idempotently. */
export const runSetup = async (
yes: boolean,
host: SetupHost = systemSetupHost(),
): Promise<SetupResult> => {
const actions: string[] = [];
const clients: Record<string, ClientConfigurationResult> = {};
const fail = async (remediation: string): Promise<SetupResult> => ({
status: "needs_human",
actions,
clients,
doctor: await host.doctor(),
remediation,
});
if (host.platform !== "darwin")
return fail("Hopper requires macOS 12 or newer.");
const macosVersion = await host.macosVersion();
if (macosVersion === undefined || major(macosVersion) < 12)
return fail("Upgrade to macOS 12 or newer.");
if (major(host.nodeVersion) < 22) return fail("Install Node.js 22 or newer.");
if (!(await host.hasHomebrew())) {
if (!yes) return fail("Re-run with --yes to install Homebrew.");
if (!(await host.installHomebrew()))
return fail(
"Homebrew installation was interrupted; resolve the installer prompt and re-run setup.",
);
actions.push("installed_homebrew");
}
if ((await host.hopperPath()) === undefined) {
if (!(await host.installHopper()))
return fail(
"Hopper installation was interrupted; resolve the system prompt and re-run setup.",
);
actions.push("installed_hopper");
}
for (const client of await host.detectedClients()) {
const result = await host.configureClient(client);
clients[client.name] = result;
if (result.status === "failed")
return fail(
`${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`,
);
if (result.status === "configured")
actions.push(`configured_${client.name}`);
}
const skill = await host.installSkill();
if (skill === "failed")
return fail("Agent skill installation or readback failed.");
if (skill === "installed") actions.push("installed_skill");
const doctor = await host.doctor();
return {
status: doctor.healthy ? "ready" : "needs_human",
actions,
clients,
doctor,
...(doctor.healthy
? {}
: {
remediation:
"Run better-binary doctor and apply each reported remediation.",
}),
};
};
/** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */
export const systemSetupHost = (): SetupHost => {
const doctorHost = systemDoctorHost();
return {
platform: process.platform,
nodeVersion: process.versions.node,
macosVersion: () => doctorHost.macosVersion(),
hasHomebrew: () => brewSucceeds(["--version"]),
installHomebrew: () =>
commandSucceeds(
"/bin/bash",
[
"-c",
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)",
],
{ ...process.env, NONINTERACTIVE: "1" },
),
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
installHopper: () =>
brewSucceeds(["install", "--cask", "hopper-disassembler"]),
detectedClients: () => detectJsonClients(homedir()),
configureClient: configureJsonClient,
installSkill: () => installCanonicalSkill(homedir()),
doctor: () => runDoctor(undefined, doctorHost),
};
};
const detectJsonClients = async (
home: string,
): Promise<readonly SetupClient[]> => {
const candidates = [
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
marker: join(home, "Library/Application Support/Claude"),
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
marker: join(home, ".cursor"),
},
];
const detected: SetupClient[] = [];
for (const candidate of candidates)
if (await exists(candidate.marker))
detected.push({ name: candidate.name, configPath: candidate.configPath });
return detected;
};
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
export const configureJsonClient = async (
client: SetupClient,
): Promise<ClientConfigurationResult> => {
let document: Record<string, unknown> = {};
let original: string | undefined;
try {
original = await readFile(client.configPath, "utf8");
document = parseObject(original);
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
const servers = objectValue(document.mcpServers);
const desired = { command: PRODUCT_IDENTITY.mcpBinary };
if (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
)
return { status: "unchanged" };
let backupPath: string | undefined;
if (original !== undefined) {
backupPath = `${client.configPath}.better-binary.backup`;
try {
await copyFile(client.configPath, backupPath);
} catch {
return { status: "failed", reason: "backup" };
}
}
document.mcpServers = {
...servers,
[PRODUCT_IDENTITY.mcpServerKey]: desired,
};
const encoded = `${JSON.stringify(document, null, 2)}\n`;
const temporary = `${client.configPath}.better-binary.tmp`;
try {
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(temporary, encoded, { encoding: "utf8", mode: 0o600 });
await rename(temporary, client.configPath);
} catch {
await rm(temporary, { force: true }).catch(() => undefined);
return { status: "failed", reason: "write" };
}
try {
const readback = parseObject(await readFile(client.configPath, "utf8"));
const value = objectValue(readback.mcpServers)[
PRODUCT_IDENTITY.mcpServerKey
];
if (JSON.stringify(value) !== JSON.stringify(desired)) {
await restoreConfig(client.configPath, original);
return { status: "failed", reason: "readback" };
}
} catch {
await restoreConfig(client.configPath, original);
return { status: "failed", reason: "readback" };
}
return {
status: "configured",
...(backupPath === undefined ? {} : { backupPath }),
};
};
const installCanonicalSkill = async (
home: string,
): Promise<"installed" | "unchanged" | "failed"> => {
const destination = join(
home,
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
);
const content = `---\nname: ${PRODUCT_IDENTITY.skillName}\ndescription: Analyze binaries with Better Binary and Hopper.\n---\n\nOpen a target with open_binary, begin with binary_overview, and close it when finished.\n`;
try {
if (
(await readFile(destination, "utf8").catch(() => undefined)) === content
)
return "unchanged";
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, content, { encoding: "utf8", mode: 0o600 });
return (await readFile(destination, "utf8")) === content
? "installed"
: "failed";
} catch {
return "failed";
}
};
const commandSucceeds = async (
command: string,
args: readonly string[],
env?: NodeJS.ProcessEnv,
): Promise<boolean> => {
try {
await execFileAsync(command, args, env === undefined ? {} : { env });
return true;
} catch {
return false;
}
};
const brewSucceeds = async (args: readonly string[]): Promise<boolean> => {
const result = await probeHomebrew(async (command) =>
(await commandSucceeds(command, args)) ? true : undefined,
);
return result === true;
};
const major = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const exists = async (path: string): Promise<boolean> => {
try {
await access(path);
return true;
} catch {
return false;
}
};
const objectSchema = z.record(z.string(), z.unknown());
const objectValue = (value: unknown): Record<string, unknown> => {
const parsed = objectSchema.safeParse(value);
return parsed.success ? parsed.data : {};
};
const parseObject = (text: string): Record<string, unknown> =>
objectValue(JSON.parse(text));
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
const restoreConfig = async (
path: string,
original: string | undefined,
): Promise<void> => {
try {
if (original === undefined) await rm(path, { force: true });
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
} catch {
// The backup remains available for the remediation reported by setup.
}
};
+17
View File
@@ -0,0 +1,17 @@
/** Standard executable names and locations used to discover Homebrew. */
export const HOMEBREW_COMMANDS = [
"brew",
"/opt/homebrew/bin/brew",
"/usr/local/bin/brew",
] as const;
/** Return the first successful result from the standard Homebrew executables. */
export const probeHomebrew = async <T>(
probe: (command: string) => Promise<T | undefined>,
): Promise<T | undefined> => {
for (const command of HOMEBREW_COMMANDS) {
const result = await probe(command);
if (result !== undefined) return result;
}
return undefined;
};
+3 -3
View File
@@ -8,14 +8,14 @@ const DEFAULT_HOPPER_LAUNCHER_PATH =
export interface AppConfig {
readonly hopperLauncherPath: string;
readonly hopperTargetPath: string;
readonly hopperTargetPath: string | undefined;
readonly hopperTargetKind: "executable" | "database";
readonly hopperLoaderArgs: readonly string[];
}
const environmentSchema = z.object({
HOPPER_LAUNCHER_PATH: z.string().min(1).optional(),
HOPPER_TARGET_PATH: z.string().min(1),
HOPPER_TARGET_PATH: z.string().min(1).optional(),
HOPPER_TARGET_KIND: z.enum(["executable", "database"]).default("executable"),
HOPPER_LOADER_ARGS_JSON: z.string().optional(),
});
@@ -27,7 +27,7 @@ export const parseConfig = (
const parsedEnvironment = environmentSchema.safeParse(environment);
if (!parsedEnvironment.success) {
return err(
new ConfigurationError("HOPPER_TARGET_PATH is required", {
new ConfigurationError("Invalid Hopper environment configuration", {
cause: parsedEnvironment.error,
}),
);
+19 -1
View File
@@ -13,7 +13,7 @@ const caseSensitive = z
.describe("Whether to match case");
/** The architectural owner of a tool implementation. */
export type ToolKind = "official-proxy" | "enhanced";
export type ToolKind = "official-proxy" | "enhanced" | "session";
/** Stable caller-visible contract retained across the Python-to-TypeScript migration. */
export interface ToolContract {
@@ -35,6 +35,12 @@ const enhanced = (
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "enhanced", inputSchema });
const session = (
name: string,
description: string,
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "session", inputSchema });
/** All 31 tools forwarded unchanged to Hopper's official MCP server. */
export const OFFICIAL_TOOL_CONTRACTS = [
official(
@@ -218,8 +224,20 @@ export const ENHANCED_TOOL_CONTRACTS = [
),
] as const satisfies readonly ToolContract[];
/** Binary lifecycle tools available before Hopper is launched. */
export const SESSION_TOOL_CONTRACTS = [
session(
"open_binary",
"Open or switch the active binary",
z.object({ path: z.string().min(1) }),
),
session("close_binary", "Close the active binary", z.object({})),
session("binary_session", "Describe the active binary session", z.object({})),
] as const satisfies readonly ToolContract[];
/** Complete caller-visible tool inventory. */
export const TOOL_CONTRACTS = [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
] as const;
+217
View File
@@ -0,0 +1,217 @@
import { constants } from "node:fs";
import { access, open, realpath } from "node:fs/promises";
import { isAbsolute, resolve } from "node:path";
import { BinaryTargetError } from "./errors.js";
import { err, ok, type Result } from "./result.js";
/** CPU families understood by the supported executable loaders. */
export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64";
/** A parsed binary target and the Hopper launcher arguments it requires. */
export interface BinaryTarget {
readonly path: string;
readonly kind: "executable" | "database";
readonly format: "hopper" | "mach-o" | "elf" | "pe";
readonly architecture?: BinaryArchitecture;
readonly availableArchitectures?: readonly BinaryArchitecture[];
readonly loaderArgs: readonly string[];
}
/** Resolve, read, and classify a local binary path, including its architecture metadata. */
export const parseBinaryTarget = async (
input: string,
cwd = process.cwd(),
hostArchitecture: NodeJS.Architecture = process.arch,
): Promise<Result<BinaryTarget, BinaryTargetError>> => {
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
try {
await access(candidate, constants.R_OK);
const path = await realpath(candidate);
if (path.toLowerCase().endsWith(".hop"))
return ok({ path, kind: "database", format: "hopper", loaderArgs: [] });
const handle = await open(path, "r");
let bytes: Buffer;
try {
bytes = Buffer.alloc(4096);
const read = await handle.read(bytes, 0, bytes.length, 0);
bytes = bytes.subarray(0, read.bytesRead);
} finally {
await handle.close();
}
const detected = parseExecutableHeader(bytes, hostArchitecture);
if (!detected.ok) return err(new BinaryTargetError(path, detected.error));
return ok({ path, kind: "executable", ...detected.value });
} catch (cause: unknown) {
return err(
new BinaryTargetError(candidate, "path is not readable", { cause }),
);
}
};
type ExecutableMetadata = Pick<
BinaryTarget,
"format" | "architecture" | "availableArchitectures" | "loaderArgs"
>;
/** Parse supported executable headers without performing I/O. */
export const parseExecutableHeader = (
bytes: Buffer,
hostArchitecture: NodeJS.Architecture,
): Result<ExecutableMetadata, string> => {
if (
bytes.length >= 4 &&
bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
)
return parseElf(bytes);
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a)
return parsePe(bytes);
if (bytes.length < 8) return err("truncated or unsupported binary header");
const magic = bytes.readUInt32BE(0);
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
return parseThinMachO(bytes, magic);
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
return parseFatMachO(bytes, magic, hostArchitecture);
return err("unsupported binary format");
};
const parseThinMachO = (
bytes: Buffer,
magic: number,
): Result<ExecutableMetadata, string> => {
if (bytes.length < 8) return err("truncated Mach-O header");
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const architecture = machArchitecture(
little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4),
);
if (architecture === undefined) return err("unsupported Mach-O architecture");
return ok({
format: "mach-o",
architecture,
availableArchitectures: [architecture],
loaderArgs: ["-l", "Mach-O"],
});
};
const parseFatMachO = (
bytes: Buffer,
magic: number,
host: NodeJS.Architecture,
): Result<ExecutableMetadata, string> => {
const little = magic === 0xbebafeca || magic === 0xbfbafeca;
const is64 = magic === 0xcafebabf || magic === 0xbfbafeca;
if (bytes.length < 8) return err("truncated FAT header");
const read = (offset: number): number =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const count = read(4);
const entrySize = is64 ? 32 : 20;
if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize)
return err("truncated or invalid FAT architecture table");
const architectures: BinaryArchitecture[] = [];
for (let index = 0; index < count; index += 1) {
const architecture = machArchitecture(read(8 + index * entrySize));
if (architecture !== undefined && !architectures.includes(architecture))
architectures.push(architecture);
}
const preferred =
host === "arm64"
? "arm64"
: host === "x64"
? "x86_64"
: host === "ia32"
? "x86"
: host === "arm"
? "arm"
: undefined;
if (preferred === undefined || !architectures.includes(preferred))
return err(`FAT binary has no host-compatible ${host} architecture`);
const flag =
preferred === "arm64"
? "--aarch64"
: preferred === "x86_64"
? "--x86_64"
: preferred === "arm"
? "--arm"
: "--x86";
return ok({
format: "mach-o",
architecture: preferred,
availableArchitectures: architectures,
loaderArgs: ["-l", "FAT", flag, "-l", "Mach-O"],
});
};
const parseElf = (bytes: Buffer): Result<ExecutableMetadata, string> => {
if (bytes.length < 20) return err("truncated ELF header");
if (bytes[4] !== 1 && bytes[4] !== 2) return err("unsupported ELF class");
const little = bytes[5] === 1;
if (!little && bytes[5] !== 2) return err("unsupported ELF endianness");
const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18);
const architecture = elfArchitecture(machine);
if (architecture === undefined) return err("unsupported ELF architecture");
return ok({
format: "elf",
architecture,
availableArchitectures: [architecture],
loaderArgs: ["-l", "ELF"],
});
};
const parsePe = (bytes: Buffer): Result<ExecutableMetadata, string> => {
if (bytes.length < 64) return err("truncated PE DOS header");
const offset = bytes.readUInt32LE(0x3c);
if (
offset > bytes.length - 6 ||
bytes.toString("binary", offset, offset + 4) !== "PE\u0000\u0000"
)
return err("invalid or truncated PE header");
const architecture = peArchitecture(bytes.readUInt16LE(offset + 4));
if (architecture === undefined) return err("unsupported PE architecture");
return ok({
format: "pe",
architecture,
availableArchitectures: [architecture],
loaderArgs: ["-l", "PE"],
});
};
const machArchitecture = (cpu: number): BinaryArchitecture | undefined => {
switch (cpu) {
case 7:
return "x86";
case 0x01000007:
return "x86_64";
case 12:
return "arm";
case 0x0100000c:
return "arm64";
}
return undefined;
};
const elfArchitecture = (machine: number): BinaryArchitecture | undefined => {
switch (machine) {
case 3:
return "x86";
case 62:
return "x86_64";
case 40:
return "arm";
case 183:
return "arm64";
}
return undefined;
};
const peArchitecture = (machine: number): BinaryArchitecture | undefined => {
switch (machine) {
case 0x14c:
return "x86";
case 0x8664:
return "x86_64";
case 0x1c0:
return "arm";
case 0xaa64:
return "arm64";
}
return undefined;
};
+22
View File
@@ -71,3 +71,25 @@ export class HopperStartError extends HopperError {
export class ConfigurationError extends HopperError {
readonly _tag = "ConfigurationError";
}
/** No binary session exists for an analysis request. */
export class NoBinaryOpenError extends HopperError {
readonly _tag = "NoBinaryOpenError";
constructor() {
super(
"No binary is open. Call open_binary with a readable local path first.",
);
}
}
/** A supplied target path could not be safely opened as a supported binary. */
export class BinaryTargetError extends HopperError {
readonly _tag = "BinaryTargetError";
constructor(
readonly path: string,
reason: string,
options?: ErrorOptions,
) {
super(`Cannot open binary: ${reason}: ${path}`, options);
}
}
+40 -34
View File
@@ -21,7 +21,6 @@ import {
} from "./protocol.js";
const MAX_LINE_BYTES = 10 * 1024 * 1024;
const MAX_ABANDONED_REQUESTS = 1_024;
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
export interface HopperClientOptions {
@@ -54,7 +53,6 @@ export class HopperClient {
> &
HopperClientOptions;
readonly #pending = new Map<number, PendingRequest>();
readonly #abandoned = new Set<number>();
#socket: Socket | undefined;
#launch: BridgeLaunch | undefined;
#directory: string | undefined;
@@ -63,6 +61,7 @@ export class HopperClient {
#buffer = "";
#closing = false;
#startPromise: Promise<Result<HopperServerInfo, HopperError>> | undefined;
#closePromise: Promise<void> | undefined;
constructor(options: HopperClientOptions) {
this.#options = {
@@ -136,29 +135,37 @@ export class HopperClient {
}
/** Stop the bridge, settle outstanding requests, and remove session artifacts. */
async close(): Promise<void> {
if (this.#closing) return;
close(): Promise<void> {
this.#closePromise ??= Promise.resolve().then(() => this.#close());
return this.#closePromise;
}
async #close(): Promise<void> {
this.#closing = true;
const socket = this.#socket;
if (socket !== undefined && !socket.destroyed) {
await this.#request("shutdown", {}, { timeoutMs: 500 }).catch(() =>
err(new HopperProcessError(null)),
);
try {
const socket = this.#socket;
if (socket !== undefined && !socket.destroyed) {
await this.#request("shutdown", {}, { timeoutMs: 500 }).catch(() =>
err(new HopperProcessError(null)),
);
}
this.#failAll(new HopperProcessError(null));
socket?.destroy();
this.#socket = undefined;
if (this.#launch?.ownsProcessLifetime === true) {
this.#launch.process.kill("SIGTERM");
}
this.#launch = undefined;
if (this.#directory !== undefined) {
await rm(this.#directory, { recursive: true, force: true });
this.#directory = undefined;
}
this.#token = undefined;
this.#startPromise = undefined;
} finally {
this.#closing = false;
this.#closePromise = undefined;
}
this.#failAll(new HopperProcessError(null));
socket?.destroy();
this.#socket = undefined;
if (this.#launch?.ownsProcessLifetime === true) {
this.#launch.process.kill("SIGTERM");
}
this.#launch = undefined;
if (this.#directory !== undefined) {
await rm(this.#directory, { recursive: true, force: true });
this.#directory = undefined;
}
this.#token = undefined;
this.#startPromise = undefined;
this.#closing = false;
}
async #connect(socketPath: string): Promise<Result<undefined, HopperError>> {
@@ -256,17 +263,20 @@ export class HopperClient {
#onData(chunk: string): void {
this.#buffer += chunk;
if (Buffer.byteLength(this.#buffer) > MAX_LINE_BYTES) {
this.#abortProtocol("Hopper response exceeded the maximum line size");
return;
}
let newline = this.#buffer.indexOf("\n");
while (newline >= 0) {
const line = this.#buffer.slice(0, newline).trim();
this.#buffer = this.#buffer.slice(newline + 1);
if (Buffer.byteLength(line) > MAX_LINE_BYTES) {
this.#abortProtocol("Hopper response exceeded the maximum line size");
return;
}
if (line.length > 0) this.#onLine(line);
newline = this.#buffer.indexOf("\n");
}
if (Buffer.byteLength(this.#buffer) > MAX_LINE_BYTES) {
this.#abortProtocol("Hopper response exceeded the maximum line size");
}
}
#onLine(line: string): void {
@@ -275,9 +285,10 @@ export class HopperClient {
this.#abortProtocol(parsed.error.message, parsed.error);
return;
}
if (this.#abandoned.delete(parsed.value.id)) return;
if (!this.#pending.has(parsed.value.id)) {
this.#abortProtocol("Hopper returned an unknown response id");
if (parsed.value.id >= this.#nextId) {
this.#abortProtocol("Hopper returned an unknown response id");
}
return;
}
this.#settle(parsed.value.id, responseResult(parsed.value));
@@ -301,11 +312,6 @@ export class HopperClient {
#abandon(id: number, result: Result<JsonValue, HopperError>): void {
if (!this.#pending.has(id)) return;
this.#abandoned.add(id);
if (this.#abandoned.size > MAX_ABANDONED_REQUESTS) {
const oldest = this.#abandoned.values().next();
if (!oldest.done) this.#abandoned.delete(oldest.value);
}
this.#settle(id, result);
}
+10
View File
@@ -0,0 +1,10 @@
/** Provisional product identifiers kept together for release-time renaming. */
export const PRODUCT_IDENTITY = {
displayName: "Better Binary",
packageName: "better-binary-mcp",
cliBinary: "better-binary",
mcpBinary: "better-binary-mcp",
mcpServerKey: "better-binary",
skillName: "better-binary-analysis",
configFileName: "better-binary.json",
} as const;
+28 -13
View File
@@ -2,6 +2,7 @@
import { pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
import { realpathSync } from "node:fs";
import {
serveStdio,
@@ -9,6 +10,7 @@ import {
} from "@modelcontextprotocol/server/stdio";
import { parseConfig } from "./config.js";
import { BinarySession } from "./application/BinarySession.js";
import { HopperApplicationLauncher } from "./hopper/BridgeLauncher.js";
import { HopperClient } from "./hopper/HopperClient.js";
import { createServer } from "./server/createServer.js";
@@ -24,24 +26,37 @@ export const run = async (): Promise<number> => {
const bridgeScriptPath = fileURLToPath(
new URL("../bridge/hopper_bridge.py", import.meta.url),
);
const hopper = new HopperClient({
launcher: new HopperApplicationLauncher({
launcherPath: config.value.hopperLauncherPath,
targetPath: config.value.hopperTargetPath,
targetKind: config.value.hopperTargetKind,
loaderArgs: config.value.hopperLoaderArgs,
bridgeScriptPath,
}),
});
const session = new BinarySession(
(target) =>
new HopperClient({
launcher: new HopperApplicationLauncher({
launcherPath: config.value.hopperLauncherPath,
targetPath: target.path,
targetKind: target.kind,
loaderArgs:
config.value.hopperLoaderArgs.length > 0
? config.value.hopperLoaderArgs
: target.loaderArgs,
bridgeScriptPath,
}),
}),
);
if (config.value.hopperTargetPath !== undefined) {
const opened = await session.open(config.value.hopperTargetPath);
if (!opened.ok) {
process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`);
return 1;
}
}
let handle: StdioServerHandle;
try {
handle = serveStdio(() => createServer(hopper), {
handle = serveStdio(() => createServer(session, session), {
onerror: () => {
process.stderr.write("MCP stdio transport error\n");
},
});
} catch {
await hopper.close();
await session.close();
process.stderr.write("Failed to start MCP stdio transport\n");
return 1;
}
@@ -50,7 +65,7 @@ export const run = async (): Promise<number> => {
const shutdown = (): Promise<void> => {
shutdownPromise ??= (async () => {
await handle.close();
await hopper.close();
await session.close();
})();
return shutdownPromise;
};
@@ -71,7 +86,7 @@ export const run = async (): Promise<number> => {
const entryPath = process.argv[1];
if (
entryPath !== undefined &&
pathToFileURL(entryPath).href === import.meta.url
pathToFileURL(realpathSync(entryPath)).href === import.meta.url
) {
run()
.then((exitCode) => {
+12 -3
View File
@@ -1,20 +1,29 @@
import { McpServer } from "@modelcontextprotocol/server";
import type { HopperToolPort } from "../application/HopperToolPort.js";
import type { BinarySession } from "../application/BinarySession.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { registerEnhancedTools } from "./registerEnhancedTools.js";
import { registerOfficialTools } from "./registerOfficialTools.js";
import { registerSessionTools } from "./registerSessionTools.js";
/** Construct one MCP server instance without acquiring subprocess resources. */
export const createServer = (hopper: HopperToolPort): McpServer => {
export const createServer = (
hopper: HopperToolPort,
session?: BinarySession,
): McpServer => {
const server = new McpServer(
{ name: "betterBinaryMCP", version: "0.1.0" },
{ name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" },
{
capabilities: { tools: {} },
instructions:
"Reverse-engineering tools for Hopper Disassembler. Start with binary_overview.",
session === undefined
? "Reverse-engineering tools for an active Hopper Disassembler target. Start with binary_overview."
: "Reverse-engineering tools for Hopper Disassembler. Open a target with open_binary, then start with binary_overview.",
},
);
registerOfficialTools(server, hopper);
registerEnhancedTools(server, hopper);
if (session !== undefined) registerSessionTools(server, session);
return server;
};
+54
View File
@@ -0,0 +1,54 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
import type { BinarySession } from "../application/BinarySession.js";
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { toCallToolResult } from "./toolResult.js";
/** Register binary lifecycle management tools. */
export const registerSessionTools = (
server: McpServer,
session: BinarySession,
): void => {
const [openContract, closeContract, statusContract] = SESSION_TOOL_CONTRACTS;
server.registerTool(
openContract.name,
{
description: openContract.description,
inputSchema: openContract.inputSchema,
},
async (input, context) => {
const parsed = z.object({ path: z.string().min(1) }).parse(input);
const opened = await session.open(parsed.path, {
signal: context.mcpReq.signal,
});
return opened.ok
? toCallToolResult({
ok: true,
value: {
path: opened.value.path,
format: opened.value.format,
kind: opened.value.kind,
loaderArgs: [...opened.value.loaderArgs],
},
})
: toCallToolResult(opened);
},
);
server.registerTool(
closeContract.name,
{
description: closeContract.description,
inputSchema: closeContract.inputSchema,
},
async () => toCallToolResult(await session.close()),
);
server.registerTool(
statusContract.name,
{
description: statusContract.description,
inputSchema: statusContract.inputSchema,
},
() => toCallToolResult({ ok: true, value: session.status() }),
);
};
+190
View File
@@ -0,0 +1,190 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
BinarySession,
type BinaryClient,
} from "../src/application/BinarySession.js";
import { HopperStartError } from "../src/domain/errors.js";
import { err, ok } from "../src/domain/result.js";
let directory: string | undefined;
afterEach(async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
describe("binary session", () => {
it("requires an open binary and closes idempotently", async () => {
const session = new BinarySession(() => client());
expect((await session.callTool("binary_overview", {})).ok).toBe(false);
expect(await session.close()).toEqual({ ok: true, value: null });
});
it("keeps the active client when a switch fails", async () => {
directory = await mkdtemp(join(tmpdir(), "bb-session-"));
const first = join(directory, "first.hop");
const second = join(directory, "second.hop");
await writeFile(first, "one");
await writeFile(second, "two");
let created = 0;
const session = new BinarySession(() => client(created++ === 1));
expect((await session.open(first)).ok).toBe(true);
expect((await session.open(second)).ok).toBe(false);
expect(session.status()).toMatchObject({ open: true });
expect(JSON.stringify(session.status())).toContain("first.hop");
expect(created).toBe(2);
});
it("serializes concurrent opens and leaves the last target active", async () => {
const [first, second] = await targets();
const clients: TestClient[] = [];
const session = new BinarySession(() => {
const value = new TestClient();
clients.push(value);
return value;
});
const one = session.open(first);
const two = session.open(second);
expect((await one).ok).toBe(true);
expect((await two).ok).toBe(true);
expect(session.status()).toMatchObject({ open: true });
expect(JSON.stringify(session.status())).toContain("second.hop");
expect(clients[0]?.closed).toBe(1);
});
it("waits for an active call before closing its client during a switch", async () => {
const [first, second] = await targets();
const active = deferred<ReturnType<typeof ok<null>>>();
const clients: TestClient[] = [];
const session = new BinarySession(() => {
const value = new TestClient(
undefined,
false,
clients.length === 0 ? active.promise : undefined,
);
clients.push(value);
return value;
});
await session.open(first);
const call = session.callTool("long", {});
const switching = session.open(second);
await Promise.resolve();
expect(clients[0]?.closed).toBe(0);
active.resolve(ok(null));
await call;
await switching;
expect(clients[0]?.closed).toBe(1);
});
it("cancels an open queued behind another transition without creating a client", async () => {
const [first, second] = await targets();
const health = deferred<ReturnType<typeof ok<null>>>();
let created = 0;
const session = new BinarySession(() => {
created += 1;
return new TestClient(created === 1 ? health.promise : undefined);
});
const opening = session.open(first);
const controller = new AbortController();
const queued = session.open(second, { signal: controller.signal });
controller.abort();
health.resolve(ok(null));
await opening;
const result = await queued;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError");
expect(created).toBe(1);
});
it("cancels a call while it waits for a transition", async () => {
const [first] = await targets();
const health = deferred<ReturnType<typeof ok<null>>>();
const session = new BinarySession(() => new TestClient(health.promise));
const opening = session.open(first);
const controller = new AbortController();
const call = session.callTool(
"overview",
{},
{ signal: controller.signal },
);
controller.abort();
const result = await call;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError");
health.resolve(ok(null));
await opening;
});
it("closes a failed candidate and preserves the previous target", async () => {
const [first, second] = await targets();
const clients: TestClient[] = [];
const session = new BinarySession(() => {
const value = new TestClient(undefined, clients.length === 1);
clients.push(value);
return value;
});
await session.open(first);
await session.open(second);
expect(clients[1]?.closed).toBe(1);
expect(clients[0]?.closed).toBe(0);
expect(JSON.stringify(session.status())).toContain("first.hop");
await session.close();
await session.close();
expect(clients[0]?.closed).toBe(1);
});
});
const client = (fail = false): BinaryClient => ({
callTool: () =>
Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
close: () => Promise.resolve(),
});
class TestClient implements BinaryClient {
closed = 0;
constructor(
readonly pendingHealth?: Promise<ReturnType<typeof ok<null>>>,
readonly failHealth = false,
readonly pendingCall?: Promise<ReturnType<typeof ok<null>>>,
) {}
callTool(name: string) {
if (name === "health")
return this.failHealth
? Promise.resolve(err(new HopperStartError()))
: (this.pendingHealth ?? Promise.resolve(ok(null)));
return this.pendingCall ?? Promise.resolve(ok(null));
}
close(): Promise<void> {
this.closed += 1;
return Promise.resolve();
}
}
const targets = async (): Promise<readonly [string, string]> => {
directory ??= await mkdtemp(join(tmpdir(), "bb-session-"));
const first = join(directory, "first.hop");
const second = join(directory, "second.hop");
await writeFile(first, "one");
await writeFile(second, "two");
return [first, second];
};
const deferred = <T>(): {
readonly promise: Promise<T>;
resolve(value: T): void;
} => {
let resolvePromise: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return {
promise,
resolve(value) {
resolvePromise?.(value);
},
};
};
+94
View File
@@ -0,0 +1,94 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
parseBinaryTarget,
parseExecutableHeader,
} from "../src/domain/binaryTarget.js";
let directory: string | undefined;
afterEach(async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
describe("executable header parsing", () => {
it.each([
[elf(1, 1, 62), "elf", "x86_64"],
[elf(2, 2, 183), "elf", "arm64"],
[thinMach(0xfeedfacf, 0x0100000c), "mach-o", "arm64"],
[thinMach(0xcefaedfe, 0x01000007), "mach-o", "x86_64"],
[pe(0x8664), "pe", "x86_64"],
[pe(0xaa64), "pe", "arm64"],
] as const)("parses metadata for %s", (bytes, format, architecture) => {
const result = parseExecutableHeader(bytes, "arm64");
expect(result.ok && result.value).toMatchObject({ format, architecture });
});
it("selects the host architecture from a FAT table", () => {
const bytes = fat([0x01000007, 0x0100000c]);
const arm = parseExecutableHeader(bytes, "arm64");
const intel = parseExecutableHeader(bytes, "x64");
expect(arm.ok && arm.value.loaderArgs).toContain("--aarch64");
expect(intel.ok && intel.value.loaderArgs).toContain("--x86_64");
});
it("rejects FAT files without a host-compatible slice", () => {
expect(parseExecutableHeader(fat([0x01000007]), "arm64")).toMatchObject({
ok: false,
});
});
it.each([
Buffer.alloc(0),
Buffer.from([0x7f, 0x45, 0x4c, 0x46]),
fat([], 2),
pe(0xffff),
])("rejects malformed, truncated, or unsupported metadata", (bytes) => {
expect(parseExecutableHeader(bytes, "arm64").ok).toBe(false);
});
});
describe("binary target I/O", () => {
it("resolves relative Hopper databases and rejects unknown or unreadable paths", async () => {
directory = await mkdtemp(join(tmpdir(), "bb-target-"));
await writeFile(join(directory, "sample.hop"), "database");
await writeFile(join(directory, "text"), "hello");
expect((await parseBinaryTarget("sample.hop", directory)).ok).toBe(true);
expect((await parseBinaryTarget("text", directory)).ok).toBe(false);
expect((await parseBinaryTarget("missing", directory)).ok).toBe(false);
});
});
const elf = (class_: number, endian: number, machine: number): Buffer => {
const bytes = Buffer.alloc(20);
bytes.set([0x7f, 0x45, 0x4c, 0x46, class_, endian]);
if (endian === 1) bytes.writeUInt16LE(machine, 18);
else bytes.writeUInt16BE(machine, 18);
return bytes;
};
const thinMach = (magic: number, cpu: number): Buffer => {
const bytes = Buffer.alloc(8);
bytes.writeUInt32BE(magic, 0);
if (magic === 0xcefaedfe || magic === 0xcffaedfe) bytes.writeUInt32LE(cpu, 4);
else bytes.writeUInt32BE(cpu, 4);
return bytes;
};
const fat = (cpus: readonly number[], declared = cpus.length): Buffer => {
const bytes = Buffer.alloc(8 + cpus.length * 20);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(declared, 4);
cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20));
return bytes;
};
const pe = (machine: number): Buffer => {
const bytes = Buffer.alloc(128);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(64, 0x3c);
bytes.write("PE\0\0", 64, "binary");
bytes.writeUInt16LE(machine, 68);
return bytes;
};
+4 -2
View File
@@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest";
import { parseConfig } from "../src/config.js";
describe("runtime configuration", () => {
it("requires a target and defaults to Hopper's documented launcher", () => {
expect(parseConfig({}).ok).toBe(false);
it("allows target-free startup and defaults to Hopper's documented launcher", () => {
const empty = parseConfig({});
expect(empty.ok).toBe(true);
if (empty.ok) expect(empty.value.hopperTargetPath).toBeUndefined();
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
expect(result.ok).toBe(true);
if (result.ok) {
+43
View File
@@ -0,0 +1,43 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
ENHANCED_TOOL_CONTRACTS,
OFFICIAL_TOOL_CONTRACTS,
SESSION_TOOL_CONTRACTS,
} from "../src/contracts/toolContracts.js";
const ORIGINAL_39_SHA256 =
"43ffddd91b775a3e00fb736c6b1a644210e5917a1e1f02745d0283ac6ab99db4";
describe("original tool contract snapshot", () => {
it("preserves every original name, description, kind, and complete JSON schema", () => {
const projection = [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
].map((contract) => ({
name: contract.name,
description: contract.description,
kind: contract.kind,
inputSchema: z.toJSONSchema(contract.inputSchema, {
target: "draft-07",
unrepresentable: "any",
}),
}));
expect(projection).toHaveLength(39);
expect(
createHash("sha256").update(JSON.stringify(projection)).digest("hex"),
).toBe(ORIGINAL_39_SHA256);
});
it("keeps exactly three additive session contracts", () => {
expect(
SESSION_TOOL_CONTRACTS.map(({ name, kind }) => ({ name, kind })),
).toEqual([
{ name: "open_binary", kind: "session" },
{ name: "close_binary", kind: "session" },
{ name: "binary_session", kind: "session" },
]);
});
});
+13
View File
@@ -1,3 +1,4 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import { ok } from "../src/domain/result.js";
@@ -11,4 +12,16 @@ describe("beta.3 server composition", () => {
it("constructs independent MCP server instances", () => {
expect(createServer(hopper)).not.toBe(createServer(hopper));
});
it("does not advertise unavailable session tools", async () => {
const server = createServer(hopper);
const client = new Client({ name: "composition-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
expect(client.getInstructions()).not.toContain("open_binary");
await client.close();
await server.close();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { runDoctor, type DoctorHost } from "../src/application/Doctor.js";
const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
platform: "darwin",
nodeVersion: "22.1.0",
macosVersion: () => Promise.resolve("12.0"),
readable: (path) => Promise.resolve(path.includes("Hopper")),
brewHopperPath: () => Promise.resolve(undefined),
manualHopperPaths: () => Promise.resolve([]),
...overrides,
});
describe("doctor", () => {
it.each(["11.7", undefined])(
"rejects unsupported macOS version %s",
async (version) => {
const result = await runDoctor(
undefined,
host({ macosVersion: () => Promise.resolve(version) }),
);
expect(result.checks.find(({ name }) => name === "macos")?.ok).toBe(
false,
);
},
);
it("detects a manual configured Hopper before Homebrew", async () => {
const result = await runDoctor(
undefined,
host({
configuredHopperPath: "/manual/Hopper",
readable: (path) => Promise.resolve(path === "/manual/Hopper"),
}),
);
expect(result.hopperPath).toBe("/manual/Hopper");
expect(result.healthy).toBe(true);
});
it("detects a Homebrew cask installed outside /Applications", async () => {
const path =
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
const result = await runDoctor(
undefined,
host({
readable: (candidate) => Promise.resolve(candidate === path),
brewHopperPath: () => Promise.resolve(path),
}),
);
expect(result.hopperPath).toBe(path);
});
it("detects a manually installed Hopper application", async () => {
const path = "/Applications/Hopper v6.app/Contents/MacOS/hopper";
const result = await runDoctor(
undefined,
host({
readable: (candidate) => Promise.resolve(candidate === path),
manualHopperPaths: () => Promise.resolve([path]),
}),
);
expect(result.hopperPath).toBe(path);
});
});
+26
View File
@@ -135,4 +135,30 @@ describe("HopperClient", () => {
value: { value: "alive" },
});
});
it("ignores more than 1,024 late responses without corrupting the session", async () => {
const client = await startClient();
const timedOut = await Promise.all(
Array.from({ length: 1_025 }, (_, index) =>
client.callTool("echo", { index, delay: 150 }, { timeoutMs: 5 }),
),
);
expect(timedOut.every((result) => !result.ok)).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 200));
await expect(client.callTool("echo", { value: "alive" })).resolves.toEqual({
ok: true,
value: { value: "alive" },
});
});
it("makes concurrent close callers await the same shutdown", async () => {
const client = await startClient();
let firstSettled = false;
const first = client.close().then(() => {
firstSettled = true;
});
await client.close();
expect(firstSettled).toBe(true);
await first;
});
});
+3 -3
View File
@@ -10,7 +10,7 @@ const fixturePath = fileURLToPath(
);
describe("production stdio runtime", () => {
it("starts the built entrypoint, lists 39 tools, calls one, and shuts down", async () => {
it("starts the built entrypoint, lists 42 tools, calls one, and shuts down", async () => {
const transport = new StdioClientTransport({
command: process.execPath,
args: [mainPath],
@@ -18,7 +18,7 @@ describe("production stdio runtime", () => {
env: {
PATH: process.env.PATH ?? "",
HOPPER_LAUNCHER_PATH: process.execPath,
HOPPER_TARGET_PATH: "/fixture/sample",
HOPPER_TARGET_PATH: process.execPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify([fixturePath]),
},
stderr: "pipe",
@@ -32,7 +32,7 @@ describe("production stdio runtime", () => {
try {
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools).toHaveLength(39);
expect(tools.tools).toHaveLength(42);
const result = await client.callTool({
name: "current_document",
arguments: {},
+80
View File
@@ -0,0 +1,80 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import {
BinarySession,
type BinaryClient,
} from "../src/application/BinarySession.js";
import { ok } from "../src/domain/result.js";
import { createServer } from "../src/server/createServer.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
let directory: string | undefined;
afterEach(async () => {
await Promise.all(resources.splice(0).map((resource) => resource.close()));
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
describe("target-free MCP lifecycle", () => {
it("reports no-target, opens, analyzes, switches, reports status, and closes", async () => {
directory = await mkdtemp(join(tmpdir(), "bb-mcp-session-"));
const first = join(directory, "first.hop");
const second = join(directory, "second.hop");
await writeFile(first, "one");
await writeFile(second, "two");
const closed: string[] = [];
const session = new BinarySession((target) => client(target.path, closed));
const server = createServer(session, session);
const mcp = new Client({ name: "session-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server);
await server.connect(serverTransport);
await mcp.connect(clientTransport);
const before = await mcp.callTool({
name: "current_document",
arguments: {},
});
expect(before.isError).toBe(true);
expect(text(before)).toContain("NoBinaryOpenError");
expect((await mcp.listTools()).tools).toHaveLength(42);
expect(
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
.isError,
).not.toBe(true);
expect(
text(await mcp.callTool({ name: "current_document", arguments: {} })),
).toContain("first.hop");
await mcp.callTool({ name: "open_binary", arguments: { path: second } });
expect(closed.some((path) => path.endsWith("first.hop"))).toBe(true);
expect(
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
).toContain("second.hop");
await mcp.callTool({ name: "close_binary", arguments: {} });
expect(
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
).toContain('"open": false');
});
});
const client = (path: string, closed: string[]): BinaryClient => ({
callTool: (name) =>
Promise.resolve(ok(name === "health" ? null : { path, name })),
close: () => {
closed.push(path);
return Promise.resolve();
},
});
const text = (result: CallToolResult): string => {
const content = result.content.find((item) => item.type === "text");
if (content?.type !== "text") throw new Error("missing text result");
return content.text;
};
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import {
runSetup,
type ClientConfigurationResult,
type SetupClient,
type SetupHost,
} from "../src/application/Setup.js";
import type { DoctorCheck } from "../src/application/Doctor.js";
class FakeSetupHost implements SetupHost {
readonly platform = "darwin" as const;
readonly nodeVersion = "22.4.0";
version: string | undefined = "14.5";
brew = false;
hopper: string | undefined;
homebrewInstallSucceeds = true;
hopperInstallSucceeds = true;
skill: "installed" | "unchanged" | "failed" = "installed";
clients: readonly SetupClient[] = [];
clientResults = new Map<string, ClientConfigurationResult>();
homebrewInstalls = 0;
hopperInstalls = 0;
configurations = 0;
macosVersion = (): Promise<string | undefined> =>
Promise.resolve(this.version);
hasHomebrew = (): Promise<boolean> => Promise.resolve(this.brew);
installHomebrew = (): Promise<boolean> => {
this.homebrewInstalls += 1;
if (this.homebrewInstallSucceeds) this.brew = true;
return Promise.resolve(this.homebrewInstallSucceeds);
};
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
installHopper = (): Promise<boolean> => {
this.hopperInstalls += 1;
if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper";
return Promise.resolve(this.hopperInstallSucceeds);
};
detectedClients = (): Promise<readonly SetupClient[]> =>
Promise.resolve(this.clients);
configureClient = (
client: SetupClient,
): Promise<ClientConfigurationResult> => {
this.configurations += 1;
return Promise.resolve(
this.clientResults.get(client.name) ?? {
status: "configured",
backupPath: `${client.configPath}.backup`,
},
);
};
installSkill = (): Promise<"installed" | "unchanged" | "failed"> =>
Promise.resolve(this.skill);
doctor = (): Promise<{
healthy: boolean;
hopperPath?: string;
checks: readonly DoctorCheck[];
}> =>
Promise.resolve({
healthy: this.brew && this.hopper !== undefined,
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
checks: [],
});
}
describe("setup workflow", () => {
it("installs Homebrew and Hopper on a clean approved machine", async () => {
const host = new FakeSetupHost();
const result = await runSetup(true, host);
expect(result.status).toBe("ready");
expect(result.actions).toEqual([
"installed_homebrew",
"installed_hopper",
"installed_skill",
]);
});
it("requires --yes before installing Homebrew", async () => {
const host = new FakeSetupHost();
const result = await runSetup(false, host);
expect(result.status).toBe("needs_human");
expect(host.homebrewInstalls).toBe(0);
expect(result.remediation).toContain("--yes");
});
it("accepts existing Homebrew and manually installed Hopper", async () => {
const host = new FakeSetupHost();
host.brew = true;
host.hopper = "/Applications/Manual Hopper";
expect((await runSetup(true, host)).actions).toEqual(["installed_skill"]);
expect(host.hopperInstalls).toBe(0);
});
it.each(["homebrew", "hopper"] as const)(
"returns needs_human when %s installation is interrupted",
async (stage) => {
const host = new FakeSetupHost();
if (stage === "homebrew") host.homebrewInstallSucceeds = false;
else {
host.brew = true;
host.hopperInstallSucceeds = false;
}
const result = await runSetup(true, host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain("interrupted");
},
);
it("configures every detected client and reports backup paths", async () => {
const host = new FakeSetupHost();
host.brew = true;
host.hopper = "/Hopper";
host.clients = [
{ name: "claude", configPath: "/claude.json" },
{ name: "cursor", configPath: "/cursor.json" },
];
const result = await runSetup(true, host);
expect(Object.keys(result.clients)).toEqual(["claude", "cursor"]);
expect(host.configurations).toBe(2);
});
it.each(["backup", "write", "readback"] as const)(
"stops with precise remediation after a %s failure",
async (reason) => {
const host = new FakeSetupHost();
host.brew = true;
host.hopper = "/Hopper";
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
host.clientResults.set("cursor", { status: "failed", reason });
const result = await runSetup(true, host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain(reason);
},
);
it("is idempotent on repeated setup", async () => {
const host = new FakeSetupHost();
host.brew = true;
host.hopper = "/Hopper";
host.skill = "unchanged";
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
host.clientResults.set("cursor", { status: "unchanged" });
const first = await runSetup(true, host);
const second = await runSetup(true, host);
expect(first.actions).toEqual([]);
expect(second.actions).toEqual([]);
expect(host.homebrewInstalls).toBe(0);
expect(host.hopperInstalls).toBe(0);
});
it.each(["11.7", undefined])(
"rejects unsupported macOS %s before mutation",
async (version) => {
const host = new FakeSetupHost();
host.version = version;
expect((await runSetup(true, host)).status).toBe("needs_human");
expect(host.homebrewInstalls).toBe(0);
},
);
});
+61
View File
@@ -0,0 +1,61 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { configureJsonClient } from "../src/application/Setup.js";
let directory: string | undefined;
afterEach(async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
describe("JSON client configuration transaction", () => {
it("preserves existing keys, creates a backup, and reads back the MCP entry", async () => {
directory = await mkdtemp(join(tmpdir(), "bb-setup-"));
const configPath = join(directory, "mcp.json");
const original =
'{"theme":"dark","mcpServers":{"other":{"command":"other"}}}\n';
await writeFile(configPath, original);
const result = await configureJsonClient({ name: "cursor", configPath });
expect(result.status).toBe("configured");
expect(await readFile(`${configPath}.better-binary.backup`, "utf8")).toBe(
original,
);
expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
theme: "dark",
mcpServers: {
other: { command: "other" },
"better-binary": { command: "better-binary-mcp" },
},
});
});
it("performs no write or second backup when configuration already matches", async () => {
directory = await mkdtemp(join(tmpdir(), "bb-setup-"));
const configPath = join(directory, "mcp.json");
await writeFile(
configPath,
'{"mcpServers":{"better-binary":{"command":"better-binary-mcp"}}}\n',
);
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
status: "unchanged",
});
await expect(
readFile(`${configPath}.better-binary.backup`, "utf8"),
).rejects.toThrow();
});
it("refuses malformed existing JSON without overwriting it", async () => {
directory = await mkdtemp(join(tmpdir(), "bb-setup-"));
const configPath = join(directory, "mcp.json");
await writeFile(configPath, "not-json");
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
status: "failed",
reason: "readback",
});
expect(await readFile(configPath, "utf8")).toBe("not-json");
});
});
+9 -3
View File
@@ -3,15 +3,21 @@ import { describe, expect, it } from "vitest";
import {
ENHANCED_TOOL_CONTRACTS,
OFFICIAL_TOOL_CONTRACTS,
SESSION_TOOL_CONTRACTS,
TOOL_CONTRACTS,
} from "../src/contracts/toolContracts.js";
describe("tool contract inventory", () => {
it("preserves the complete 31 plus 8 tool surface", () => {
it("preserves the original 39 contracts and adds three session tools", () => {
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31);
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8);
expect(TOOL_CONTRACTS).toHaveLength(39);
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(39);
expect(SESSION_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([
"open_binary",
"close_binary",
"binary_session",
]);
expect(TOOL_CONTRACTS).toHaveLength(42);
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(42);
});
it("retains documented enhanced-tool limits at the input boundary", () => {