Skip to content
Conduit

mcp server

MachO-explorer

The reader you can read. Dependency-free Mach-O introspection for reverse engineers, security researchers, and build engineers — fat headers, symbols, instruction boundaries and branch edges, with a v

ranjithrajv/MachO-explorercommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • Node.js 20 or newer
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "macho-explorer": {
      "command": "npx",
      "args": [
        "-y",
        "macho-explorer"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 21, 2026

MachO-explorer

test licence: MPL-2.0 runtime dependencies: 0 Node ≥ 22.15

Inspect Apple binaries from anywhere. otool, nm and class-dump need a Mac. MachO-explorer reads Mach-O on Linux, Windows, in a browser tab, and on a phone — and nothing leaves the machine.

Mach-O introspection that machines and humans can both read. One dependency-free file you can audit end to end, one versioned JSON contract, and an MCP server in the box.

▶ Try it live · Install · What it is · User manual

Drop a fat binary, get its slices, symbols and structure — computed in the tab. The bytes never leave the machine.

Verified 2026-10-07. 0 runtime dependencies · the reader is src/macho.mjs, one file importing only node:fs and node:crypto · 776 checks in the reader suite, on binaries it was not written for · in the browser it returns byte-for-byte the same answers as Node. Re-run every one of those claims in five seconds, below.

What it is

Mach-O binary introspection for macOS, iOS, iPadOS, tvOS, watchOS and visionOS binaries — thin or universal, 32- or 64-bit, arm64, arm64e, arm64_32, x86_64, i386, armv7, armv7k. Fat headers, symbol tables, sections, __text, instruction boundaries and direct branch edges.

It knows nothing about any application — no formats, no products, no save files. Every answer is a fact about the file format or about the bytes.

Mach-O is the whole scope, chosen. This is not a toolkit that happens to include one; that focus is what pays for the depth. Under the hood it decodes instruction boundaries and direct branch displacements — lengths and edges, which are facts about the bytes — and stops there. It does not print mnemonics or operands, and it will not grow to.

And underneath all of it: the reader you can read. src/macho.mjs imports only node:fs and node:crypto. Copy it into an empty directory and it parses real binaries — no node_modules, no build step, no network.

Who it is for

  • Anyone without a Mac. Triage a macOS or iOS binary on Linux, Windows, or a phone, where otool and nm do not exist.
  • Anyone holding a sensitive binary. The reader runs where the file is, so nothing is uploaded — no server, no cloud, no upload step to audit.
  • CI authors. audit's exit status and assert turn structure into a policy; fingerprint + diff stop a rebuilt binary from reading as a changed program.
  • Reverse engineers who script. Stable JSON and stable exit codes, with no GUI in the way.
  • Agent builders. An MCP server and an Agent Skill ship in the box.

Install

npm install -g macho-explorer        # Node ≥ 22.15

Zero dependencies. Or vendor src/. Or copy src/macho.mjs alone — the whole raw reader, one auditable file, no lockfile and no install step. Running from a clone needs nothing but Node:

git clone https://github.com/ranjithrajv/MachO-explorer && cd MachO-explorer
node src/macho-explorer.mjs describe /usr/local/go/bin/go

Quick start

macho-explorer describe /usr/local/go/bin/go              # what is in this file?
macho-explorer sym runtime.main /usr/local/go/bin/go      # find a symbol by name
macho-explorer symlookup 0x100085c30 -b /usr/local/go/bin/go
macho-explorer findcall --list /usr/local/go/bin/go 20    # the busiest call sites
macho-explorer a2o 0x100085c30 -b /usr/local/go/bin/go    # vaddr → file offset
macho-explorer disasm 0x100085c30 /usr/local/go/bin/go    # boundaries + branch edges
macho-explorer findcall --json 0x100085c30 /usr/local/go/bin/go | jq '.count'

The tools

describeWhat is in this file? Every slice, architecture, platform (ios, macos, …), filetype, extent, symbol counts, __TEXT bounds, UUID, header flags, entry point, rpaths, source version, dylibs. --sections / --segments / --loads for full listings
overviewThe whole picture in one call, plus --symbols / --strings on request — and a notRead list of what this package does not parse
symSearch a symbol table by substring, or --regex. Imports marked, not shown as 0x0
symlookupWhich function contains this vaddr? Reads symbols directly; nm on a large universal binary is unusable
startsWhere do functions begin? The linker's LC_FUNCTION_STARTS list — the only one a stripped binary carries
findcallDirect call/jmp xrefs to an address, or --list for the distinct targets a binary calls
findliteralA byte literal anywhere in the file, per slice, with context — or --strings to list what is already there
mapliteralLiteral → vaddrs → the pointers to it. How you find the code that handles a format
a2o / o2aVirtual address ↔ file offset, every slice's answer, with zero-fill as its own case
dumpThe bytes at a vaddr, resolved through its section and bounded by it
disasmInstruction boundaries and resolved direct branch edges for arm64, arm64e, x86_64 — bytes, not mnemonics
auditIs the file internally consistent? Its exit status is the product — a gate, not a report
fingerprintIs this the same program as that one? A digest that survives a rebuild
diffWhat changed between two binaries — structural facts, so a rebuilt pair is not a changed program
assertA CI policy: --has-symbol, --no-symbol, --has-string, --no-string, repeatable
tbdWhat does this library export? A .tbd text stub — the only readable record of a system dylib since macOS 11
symbolicateTurn a crash report into named frames, and say why every frame it could not name was not named
mcpServe the tools over the Model Context Protocol (JSON-RPC on stdin/stdout)

Why it is different

  • Runs where the binary is. The same reader runs on macOS, Linux and Windows, client-side in a browser tab, and under Termux on Android — so a sensitive binary can be triaged with nothing leaving the machine. No upload, no server, no cloud.
  • One file you can read. src/macho.mjs imports only node:fs and node:crypto. Copy it into an empty directory and it parses real binaries — no node_modules, no build step, no network. Audit it over a coffee: AUDITABILITY.md is the argument and the five-second recipe.
  • Built for machines first. Every tool emits one versioned JSON envelope under --json, a documented exit-code contract, and a notRead list on every payload. An MCP server and an Agent Skill ship in the box.
  • Honest by construction. A negative answer is a value, not an exception: found nothing (exit 1) is never confused with could not look (exit 3). Every omission is named, in the answer and in What it will not do.

Three things worth seeing

A crash report becomes names. symlookup answers for an address you already have; a crash report is a stack of them, in binaries you did not build, and not always addresses — an .ips frame is an offset into an image, a legacy .crash frame is absolute. symbolicate reads both, says which, and gives every frame it cannot name a reason (most are in Apple frameworks, which since macOS 11 are not files on disk).

$ macho-explorer symbolicate MyApp-2026-10-07.ips
  MyApp-2026-10-07.ips  —  ips, MyApp, macOS 15.0
  exception EXC_BAD_ACCESS (SIGSEGV)

  Thread 1  (crashed)
     0  -[MyController handleTap:] [binary]
     1  main [report]
     2  (unresolved)
        why not: not on disk — a modern Apple system library lives in the
                 dyld shared cache, so there is no file to read

What does libSystem export? Since macOS 11 the system libraries are not files, so nm and otool have nothing to open — the question is unanswerable by anything that reads binaries. The answer is in the SDK, as text:

$ macho-explorer tbd --symbol=_pthread_mutex_lock --sdk="$SDK/usr/lib"
_pthread_mutex_lock  —  4 hit(s) in 2 libraries, from 394 stub(s)
  /usr/lib/system/libsystem_pthread.dylib

Same program? What changed? "Is this the same thing?" has three answers, and a byte comparison gets all three wrong — PIE and ASLR move every address, while a templated binary with one function renamed is structurally near-identical. fingerprint survives a rebuild; diff says what actually changed.

$ macho-explorer fingerprint v1.0/libthing.dylib v1.1/libthing.dylib
  same program, rebuilt
$ macho-explorer diff v1.0/libthing.dylib v1.1/libthing.dylib
  same program, rebuilt  —  0 difference(s), 1 build-metadata change(s)

Prove it on real binaries

Stock system binaries, no fixtures and no setup — the reader on files it did not create. /usr/lib/dyld is the dynamic linker: a universal MH_DYLINKER with four thousand symbols per slice, not a toy built to flatter the parser.

$ macho-explorer describe /usr/lib/dyld
/usr/lib/dyld — 2.4 MB, universal, 2 slice(s)

  x86_64   file 16384..1161760  64-bit  MH_DYLINKER  macos  4,041 defined / 4,041 symbols  1 code section(s) __text 0x1000+606,251
           uuid cf55ca34-3ecb-38f3-9e3e-e20592c197be
           flags MH_NOUNDEFS MH_DYLDLINK MH_TWOLEVEL
           minos 26.6.0  sdk 26.6.1
  arm64e   file 1163264..2562000  64-bit  MH_DYLINKER  macos  4,386 defined / 4,386 symbols  1 code section(s) __text 0x1000+647,224

$ macho-explorer audit /usr/lib/dyld
/usr/lib/dyld — OK  0 error(s), 0 warning(s)

$ macho-explorer findcall --list /usr/lib/dyld 2
4577 distinct direct call/jmp target(s), most-called first  (1 MB of code scanned)
  0x000000091802  397 site(s)
  0x000000001ce6  314 site(s)

Every number above is from a stock binary; run the three commands and compare.

Gate a build on it

audit's exit status is the product, and assert turns structure into policy. The composite action runs the reader from the checked-out source — no install step — so the gate tests this commit rather than whatever a tag resolved to today.

- uses: ranjithrajv/MachO-explorer@main
  with:
    binary: build/Some.app/Contents/MacOS/Some
    baseline: known-good/Some        # enables fingerprint + diff
    sarif: true                      # upload to Code Scanning

See Gating a build for the exit-status taxonomy and SARIF, and action.yml for the inputs.

For agents and pipelines

If a program, not a person, reads the answer, this is the surface it gets.

  • One versioned envelope, always. The same JSON object on success and failure — schemaVersion, tool, ok, binary, errors, data. stdout carries JSON only; progress and prose go to stderr. The version travels in every response so a consumer fails loudly on a change instead of reading a renamed field as empty.
  • Omissions travel with the answer. data.notRead names what this package does not parse, so an empty field is evidence rather than silence: "the binary has none" stays distinct from "the reader does not look".
  • Addresses are "0x…" strings, because a 64-bit vaddr does not survive a JSON number and a silently truncated address looks exactly like a correct one.
  • Two doorways. An MCP server for an agent that speaks the protocol, and an Agent Skill for one that drives the CLI or the library.
claude mcp add macho -- node /absolute/path/to/src/mcp.mjs
import { describe, lookupAddress, findCalls, mapLiteral } from 'macho-explorer';

const { slices } = describe('/path/to/binary');
const fn = lookupAddress('/path/to/binary', 0x100085c30n);
const callers = findCalls('/path/to/binary', fn.start);

Full detail: Driving it from an agent and the machine contract.

What it will not do

A gap and a refusal read the same in a table, and only one of them survives a contributor with good intentions. These are refusals:

Will notBecause
Disassemble to textdisasm gives instruction lengths and direct branch edges — the last step a byte-level reader can take without guessing. Mnemonics, operands and a CFG are a disassembler's job, and duplicating it is how you become a worse one
Resolve indirect / PLT callsThe target is not in the instruction; a wrong edge here would look like a call graph
Read dSYM / DWARFA different file format, for the minority of binaries whose symbols are in a sidecar
Parse ObjC/Swift metadataAnother project does it properly; duplicating it makes this a worse version of that one
ELF or PEMach-O is the focus, not the first of four
FAT64 containersA fat header with 64-bit slice offsets is refused rather than half-read; reading its wider offset as 32-bit resolves a slice into the file's first 4 GiB
Code signing, fixups, export triesCovered elsewhere, and none of them changes which function an address lands in

When to use it — and when not to

Reach for this when you want one dependency-free file you can vendor or read end to end; when you are on Linux or Windows pointing at a Mac binary that otool and nm cannot touch; when the binary is large and universal; when you want scriptable, composable answers rather than a GUI; or when you need literal-level triage — where a format magic is, what it maps to, and what points at it.

Reach for something else when you want Mach-O and everything around it (code signing, fixups, ObjC/Swift metadata, firmware images, the dyld shared cache), a complete general-purpose parser, several file formats in one dependency, or a disassembler. The honest comparison — including the exact rows where a general parser wins — is in When to use this.

Limits

  • Direct calls only. Indirect calls and PLT stubs do not encode their target, so they are absent from findcall. Every hit is a site worth disassembling, not a proven edge.
  • The x86_64 scan is typed by section, not instruction. Data false positives are removed, but a byte inside a multi-byte instruction can still match. The file format records no alignment, so nothing short of a disassembler fixes it. The arm64 path steps 4 bytes and sees only aligned BLs.
  • Stripped binaries have no names. sym and symlookup report nothing rather than guess; findcall and findliteral read bytes and are unaffected.
  • Verified on macOS and Linux. The reader is portable buffer arithmetic; on Windows only the generated half of the suite runs.

Verify it yourself

Every correctness claim here is reproducible on a clean checkout, offline, with nothing installed — no network, no fixture download:

git clone https://github.com/ranjithrajv/MachO-explorer && cd MachO-explorer

node test/fixtures.mjs --check    #   ~0.1s   the corpus matches its generator
node test/smoke.mjs               #   ~4s     the tools, on binaries they were not written for
node test/mcp.mjs                 #  ~15s     the protocol, driven over a real pipe
node test/schemas.mjs             #   ~3s     every tool's output, against its own schema
node test/mutation-check.mjs      #   ~2m     the historical defects are still caught

The counts and verdicts are the claim; the timings only set expectations. mutation-check.mjs reintroduces one real historical bug at a time and requires the suite to fail — and a green npm run test:all is also a complete rot check. CONTRIBUTING.md explains what each suite establishes and why the suite runs against two corpora; FINDINGS.md records the defects that were live while the suite was green.

Documentation

  • Architecture — how the system is layered, and why each decision was made
  • Auditability — the one-file reader, and how to verify it
  • Findings — the defects that were live while the suite was green, and the check each one now has
  • User manual — every subcommand, every flag, and what each refuses to do
  • When to use this — the right and wrong tool for the job
  • The machine contract — one envelope, four exit codes, a JSON Schema per tool
  • Driving it from an agent — MCP, the skill, and what wastes an agent's time
  • Gating a build — exit-status taxonomy, SARIF, the composite action
  • Text stubs — what a system dylib exports, now that it is not a file
  • Conformance corpus — hold any Mach-O parser to known answers
  • Browser demo — the reader running client-side, with no upload

Contributing

Pull requests welcome, with one thing to read first: the scope above is a decision, not a backlog. CONTRIBUTING.md covers the test for anything on the "will not do" list, the no-application-knowledge boundary, the gate a change has to pass, and what a new tool owes the package.

If this is useful to you, a ⭐ helps the next person find it.

Licence

MPL-2.0. See LICENSE. It has no opinion about, and no access to, the contents of the files it is pointed at. Nothing proprietary ships here — no key material, no product data, no disassembly of any product, no asset bytes — and that is enforced rather than reviewed: boundary: checks scan src/, test/ and the published tarball and fail on a vendor name, title or container format appearing in any of them.

↑↓ navigate openesc close