Merge pull request #46 from morluto/morl/reverse_improvements

Evolve REA into an evidence-backed analysis platform
This commit is contained in:
morluto 2026-07-12 19:36:51 +08:00 committed by GitHub
commit 9f60348d1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 2756 additions and 375 deletions

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42-tools-for-deeper-investigation)
[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#46-tools-for-deeper-investigation)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[Quick start](#quick-start) · [See the workflow](#one-prompt-a-full-investigation) · [From app to feature](#from-app-to-feature) · [42 tools](#42-tools-for-deeper-investigation) · [How it works](#how-it-works) · [FAQ](#faq)
[Quick start](#quick-start) · [See the workflow](#one-prompt-a-full-investigation) · [From app to feature](#from-app-to-feature) · [46 tools](#46-tools-for-deeper-investigation) · [How it works](#how-it-works) · [FAQ](#faq)
<br />
@ -147,13 +147,13 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
- Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
- Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
## 42 tools for deeper investigation
## 46 tools for deeper investigation
| Tool family | Count | Examples |
| ----------------- | ----: | ------------------------------------------------------------------------------------------------------ |
| Binary inspection | 31 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
| Composed analysis | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift and ObjC discovery |
| Binary session | 3 | `open_binary`, `binary_session`, `close_binary` |
| Tool family | Count | Examples |
| ----------------- | ----: | -------------------------------------------------------------------------------------------------------------------------- |
| Binary inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
| Composed analysis | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift and ObjC discovery |
| Binary session | 3 | `open_binary`, `binary_session`, `close_binary` |
## Using REA with other coding agents

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#منصة-من-42-أداة)
[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#منصة-من-46-أداة)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [42 أداة](#منصة-من-42-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [46 أداة](#منصة-من-46-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
<br />
@ -145,12 +145,12 @@ npx -y @morluto/rea doctor
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
## منصة من 42 أداة
## منصة من 46 أداة
| عائلة الأدوات | العدد | أمثلة |
| --------------------- | ----: | ----------------------------------------------------------------------------------- |
| فحص الملفات التنفيذية | 31 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
| التحليل المركب | 8 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| جلسة الملف التنفيذي | 3 | `open_binary` و`binary_session` و`close_binary` |
## استخدام REA مع وكلاء برمجة آخرين

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42-ツールのワークベンチ)
[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#46-ツールのワークベンチ)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [42 ツール](#42-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [46 ツール](#46-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
<br />
@ -135,13 +135,13 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
- Swift / Objective-C のメタデータを解析する。
- Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。
## 42 ツールのワークベンチ
## 46 ツールのワークベンチ
| ツール群 | 数 | 例 |
| ------------------ | --: | ----------------------------------------------------------------------------------------------- |
| バイナリ調査 | 31 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
| 合成解析 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
| バイナリセッション | 3 | `open_binary`, `binary_session`, `close_binary` |
| ツール群 | 数 | 例 |
| ------------------ | --: | ------------------------------------------------------------------------------------------------------------------- |
| バイナリ調査 | 33 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
| 合成解析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
| バイナリセッション | 3 | `open_binary`, `binary_session`, `close_binary` |
## 他のコーディングエージェントで使う

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42개-도구로-구성된-워크벤치)
[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#46개-도구로-구성된-워크벤치)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [42개 도구](#42개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [46개 도구](#46개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
<br />
@ -135,13 +135,13 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
- Swift 및 Objective-C 메타데이터를 분석합니다.
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
## 42개 도구로 구성된 워크벤치
## 46개 도구로 구성된 워크벤치
| 도구 그룹 | 수 | 예시 |
| ------------- | --: | ------------------------------------------------------------------------------------------------ |
| 바이너리 검사 | 31 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
| 합성 분석 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
| 바이너리 세션 | 3 | `open_binary`, `binary_session`, `close_binary` |
| 도구 그룹 | 수 | 예시 |
| ------------- | --: | -------------------------------------------------------------------------------------------------------------------- |
| 바이너리 검사 | 33 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
| 합성 분석 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
| 바이너리 세션 | 3 | `open_binary`, `binary_session`, `close_binary` |
## 다른 코딩 에이전트에서 사용하기

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42-个工具组成的工作台)
[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#46-个工具组成的工作台)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [42 个工具](#42-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [46 个工具](#46-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
<br />
@ -135,13 +135,13 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
- 分析 Swift 和 Objective-C 元数据。
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
## 42 个工具组成的工作台
## 46 个工具组成的工作台
| 工具类别 | 数量 | 示例 |
| ---------- | ---: | ------------------------------------------------------------------------------------------------ |
| 二进制检查 | 31 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
| 组合分析 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
| 二进制会话 | 3 | `open_binary`, `binary_session`, `close_binary` |
| 工具类别 | 数量 | 示例 |
| ---------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
| 二进制检查 | 33 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
| 二进制会话 | 3 | `open_binary`, `binary_session`, `close_binary` |
## 与其他编程智能体一起使用

View File

@ -85,6 +85,86 @@ def _procedure_name(procedure):
return procedure.getSegment().getNameAtAddress(entry) or _hex(entry)
def _procedure_identity(procedure):
return {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure)}
def _containing_procedure(document, address):
segment = document.getSegmentAtAddress(address)
if segment is None:
return None, "outside_segments"
procedure = segment.getProcedureAtAddress(address)
return (procedure, None) if procedure is not None else (None, "not_in_procedure")
def _instruction_addresses(procedure, limit):
result = []
seen = set()
segment = procedure.getSegment()
truncated = False
for block in procedure.basicBlockIterator():
address = block.getStartingAddress()
end = block.getEndingAddress()
while address < end and address not in seen:
if len(result) >= limit:
truncated = True
return result, truncated
seen.add(address)
instruction = segment.getInstructionAtAddress(address)
if instruction is None:
break
result.append(address)
length = instruction.getInstructionLength()
if length <= 0:
break
address += length
return result, truncated
def _procedure_references(document, params):
procedure = _procedure(document, params.get("procedure"))
direction = params.get("direction", "outgoing")
offset = params.get("offset", 0)
limit = params.get("limit", 100)
max_instructions = params.get("max_instructions", 500)
if direction not in ("incoming", "outgoing"):
raise ValueError("direction must be incoming or outgoing")
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
raise ValueError("offset must be a non-negative integer")
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500:
raise ValueError("limit must be an integer between 1 and 500")
if not isinstance(max_instructions, int) or isinstance(max_instructions, bool) or max_instructions < 1 or max_instructions > 5000:
raise ValueError("max_instructions must be an integer between 1 and 5000")
addresses, scan_truncated = _instruction_addresses(procedure, max_instructions)
edges = set()
for address in addresses:
segment = _segment(document, address)
references = segment.getReferencesFromAddress(address) if direction == "outgoing" else segment.getReferencesOfAddress(address)
for reference in references:
edges.add((address, reference) if direction == "outgoing" else (reference, address))
ordered = sorted(edges)
items = []
selected = ordered[offset:offset + limit]
for source, target in selected:
source_procedure, _ = _containing_procedure(document, source)
target_procedure, _ = _containing_procedure(document, target)
items.append({
"source_address": _hex(source),
"target_address": _hex(target),
"source_procedure": _procedure_identity(source_procedure) if source_procedure is not None else None,
"target_procedure": _procedure_identity(target_procedure) if target_procedure is not None else None,
"kind": _unavailable("Hopper's public Python API does not classify reference kinds"),
})
next_offset = offset + len(selected)
has_more = next_offset < len(ordered)
truncated = scan_truncated or has_more
return {
"procedure": _procedure_identity(procedure), "direction": direction,
"references": {"items": items, "total": None if scan_truncated else len(ordered), "returned": len(items), "truncated": truncated, "next_offset": next_offset if has_more and not scan_truncated else None},
"instructions_scanned": len(addresses), "instruction_scan_truncated": scan_truncated,
}
def _procedure_map(document):
result = {}
for segment in document.getSegmentsList():
@ -102,7 +182,33 @@ def _strings(document):
return result
def _assembly(procedure):
def _page(values, offset, limit):
"""Return one deterministically address-sorted page without crossing an unbounded map."""
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
raise ValueError("offset must be a non-negative integer")
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500:
raise ValueError("limit must be an integer between 1 and 500")
ordered = sorted(values.items(), key=lambda item: int(item[0], 16))
selected = ordered[offset:offset + limit]
total = len(ordered)
next_offset = offset + len(selected)
has_more = next_offset < total
return {
"items": [{"address": address, "value": value} for address, value in selected],
"offset": offset,
"limit": limit,
"total": total,
"next_offset": next_offset if has_more else None,
"has_more": has_more,
}
def _unavailable(reason):
"""Describe evidence the public Hopper API cannot truthfully provide."""
return {"available": False, "reason": reason}
def _assembly(procedure, limit=None):
"""Render bounded assembly while guarding against malformed instruction cycles."""
lines = []
segment = procedure.getSegment()
@ -118,6 +224,8 @@ def _assembly(procedure):
arguments = [instruction.getFormattedArgument(index) for index in range(instruction.getArgumentCount())]
suffix = ", ".join(value for value in arguments if value is not None)
lines.append("%s: %s%s" % (_hex(address), instruction.getInstructionString(), (" " + suffix) if suffix else ""))
if limit is not None and len(lines) >= limit:
return "\n".join(lines)
length = instruction.getInstructionLength()
if length <= 0:
break
@ -125,6 +233,61 @@ def _assembly(procedure):
return "\n".join(lines)
def _analyze_function(document, params):
"""Collect a bounded, single-pass function dossier for agent callers."""
procedure = _procedure(document, params.get("procedure"))
limit = params.get("limit", 100)
max_chars = params.get("max_pseudocode_chars", 20000)
max_instructions = params.get("max_instructions", 500)
if not isinstance(limit, int) or limit < 1 or limit > 500:
raise ValueError("limit must be an integer between 1 and 500")
if not isinstance(max_chars, int) or max_chars < 1 or max_chars > 100000:
raise ValueError("max_pseudocode_chars must be between 1 and 100000")
if not isinstance(max_instructions, int) or max_instructions < 1 or max_instructions > 5000:
raise ValueError("max_instructions must be between 1 and 5000")
blocks = []
all_blocks = list(procedure.basicBlockIterator())
for block in all_blocks[:limit]:
blocks.append({
"start": _hex(block.getStartingAddress()),
"end": _hex(block.getEndingAddress()),
"successors": _unavailable(
"Hopper's public Python API does not expose CFG successor edges"
),
})
pseudo = procedure.decompile() or ""
assembly_sample = _assembly(procedure, max_instructions + 1).splitlines() if params.get("include_assembly", False) else []
assembly_truncated = len(assembly_sample) > max_instructions
assembly = assembly_sample[:max_instructions]
callers = [_procedure_name(item) for item in procedure.getAllCallerProcedures()]
callees = [_procedure_name(item) for item in procedure.getAllCalleeProcedures()]
def bounded(items):
return {"items": items[:limit], "total": len(items), "returned": min(len(items), limit), "truncated": len(items) > limit, "next_offset": limit if len(items) > limit else None}
entry = procedure.getEntryPoint()
segment = procedure.getSegment()
comments = []
comment = segment.getCommentAtAddress(entry)
inline_comment = segment.getInlineCommentAtAddress(entry)
if comment:
comments.append({"address": _hex(entry), "kind": "comment", "text": comment})
if inline_comment:
comments.append({"address": _hex(entry), "kind": "inline", "text": inline_comment})
incoming = []
for candidate in document.getSegmentsList():
incoming.extend(_hex(value) for value in candidate.getReferencesOfAddress(entry))
incoming = sorted(set(incoming), key=lambda value: int(value, 16))
return {
"procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "signature": procedure.signatureString(), "locals": _json_safe(procedure.getLocalVariableList())},
"pseudocode": {"text": pseudo[:max_chars], "total_chars": len(pseudo), "returned_chars": min(len(pseudo), max_chars), "truncated": len(pseudo) > max_chars, "next_offset": max_chars if len(pseudo) > max_chars else None},
"assembly": {"items": assembly, "total": None if assembly_truncated else len(assembly), "returned": len(assembly), "truncated": assembly_truncated, "next_offset": len(assembly) if assembly_truncated else None},
"comments": bounded(comments), "callers": bounded(callers), "callees": bounded(callees),
"incoming_references": bounded(incoming),
"referenced_strings": _unavailable("Hopper's public API does not expose typed outgoing string references for this traversal"),
"referenced_names": _unavailable("Hopper's public API does not expose typed outgoing name references for this traversal"),
"basic_blocks": bounded(blocks),
}
def _dispatch(method, params):
"""Dispatch only the closed operation set implemented by REA's public tools."""
global _selected_document
@ -143,6 +306,17 @@ def _dispatch(method, params):
document = _document(params.get("document"))
if method == "analyze_function":
return _analyze_function(document, params)
if method == "resolve_containing_procedure":
address = _address(document, params.get("address"))
procedure, reason = _containing_procedure(document, address)
if procedure is None:
return {"query_address": _hex(address), "found": False, "procedure": None, "reason": reason}
return {"query_address": _hex(address), "found": True, "procedure": _procedure_identity(procedure)}
if method == "procedure_references":
return _procedure_references(document, params)
if method == "current_address":
return _hex(document.getCurrentAddress())
if method == "current_procedure":
@ -175,21 +349,37 @@ def _dispatch(method, params):
for segment in document.getSegmentsList():
start = segment.getStartingAddress()
sections = [{"name": section.getName(), "start": _hex(section.getStartingAddress()), "end": _hex(section.getStartingAddress() + section.getLength())} for section in segment.getSectionsList()]
result.append({"name": segment.getName(), "start": _hex(start), "end": _hex(start + segment.getLength()), "writable": False, "executable": False, "sections": sections})
result.append({
"name": segment.getName(),
"start": _hex(start),
"end": _hex(start + segment.getLength()),
"writable": None,
"executable": None,
"permissions": _unavailable(
"Hopper's public Python API does not expose segment permissions"
),
"sections": sections,
})
return result
if method == "list_procedures":
return _procedure_map(document)
return _page(_procedure_map(document), params.get("offset", 0), params.get("limit", 100))
if method == "list_strings":
values = _strings(document)
requested = params.get("address")
return values if requested is None else values.get(_hex(_address(document, requested)))
if requested is not None:
key = _hex(_address(document, requested))
values = {key: values[key]} if key in values else {}
return _page(values, params.get("offset", 0), params.get("limit", 100))
if method == "list_names":
result = {}
for segment in document.getSegmentsList():
for item in segment.getNamedAddresses():
result[_hex(item)] = segment.getNameAtAddress(item)
requested = params.get("address")
return result if requested is None else result.get(_hex(_address(document, requested)))
if requested is not None:
key = _hex(_address(document, requested))
result = {key: result[key]} if key in result else {}
return _page(result, params.get("offset", 0), params.get("limit", 100))
if method in ("search_procedures", "search_strings"):
flags = 0 if params.get("case_sensitive", False) else re.IGNORECASE
expression = re.compile(params["pattern"], flags)

View File

@ -63,6 +63,8 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
"verify:package": "npm run build && node scripts/verify-package.mjs",
"config:print": "node scripts/print-mcp-config.mjs",
"docs:generate": "typedoc",

View File

@ -0,0 +1,223 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { arch, platform } from "node:os";
import { join, relative, resolve } from "node:path";
import { promisify } from "node:util";
import {
generateLargeFixture,
LARGE_FIXTURE_COUNT,
sha256,
sourceDigest,
} from "./lib/conformance-fixtures.mjs";
const exec = promisify(execFile);
const root = process.cwd();
const sourceRoot = join(root, "tests/conformance");
const outputRoot = resolve(
process.env.REA_CONFORMANCE_BUILD ?? join(root, "build/conformance"),
);
if (platform() !== "darwin")
throw new Error(
"Mach-O conformance fixtures require macOS and Xcode command-line tools",
);
await rm(outputRoot, { recursive: true, force: true });
await mkdir(outputRoot, { recursive: true });
const clang = (await exec("xcrun", ["--find", "clang"])).stdout.trim();
const clangVersion = (await exec(clang, ["--version"])).stdout.split("\n")[0];
const sdkPath = (await exec("xcrun", ["--show-sdk-path"])).stdout.trim();
const architecture = arch();
const common = [
"-arch",
architecture,
"-isysroot",
sdkPath,
"-O0",
"-g",
"-fno-inline",
];
const fixtures = [];
await build(
"c",
["c/fixture.c"],
[...common, join(sourceRoot, "c/fixture.c"), "-o", join(outputRoot, "c")],
{
expectations: {
symbols: ["_rea_entry", "_rea_branch", "_rea_leaf", "_rea_c_global"],
strings: ["REA_C_ENTRY", "REA_C_LEAF"],
},
},
);
await build(
"version-v1",
["versions/v1.c"],
[
...common,
join(sourceRoot, "versions/v1.c"),
"-o",
join(outputRoot, "version-v1"),
],
{
expectations: {
symbols: ["_rea_version_entry", "_rea_version_leaf"],
strings: ["REA_VERSION_ONE"],
},
},
);
await build(
"version-v2",
["versions/v2.c"],
[
...common,
join(sourceRoot, "versions/v2.c"),
"-o",
join(outputRoot, "version-v2"),
],
{
expectations: {
symbols: ["_rea_version_entry", "_rea_version_leaf", "_rea_added"],
strings: ["REA_VERSION_TWO"],
},
},
);
const largeSource = generateLargeFixture();
const largePath = join(outputRoot, "large.c");
await writeFile(largePath, largeSource);
await build(
"large",
[{ path: "generated/large.c", content: largeSource }],
[...common, largePath, "-o", join(outputRoot, "large")],
{
expectations: {
symbolPrefix: "_rea_page_",
symbolCount: LARGE_FIXTURE_COUNT,
stringPrefix: "REA_PAGE_",
stringCount: LARGE_FIXTURE_COUNT,
},
},
);
await build(
"objc",
["objc/fixture.m"],
[
...common,
"-fobjc-arc",
join(sourceRoot, "objc/fixture.m"),
"-framework",
"Foundation",
"-o",
join(outputRoot, "objc"),
],
{
expectations: {
symbols: ["_OBJC_CLASS_$_REAWidget"],
strings: ["REAWidget", "REAWidgetDelegate", "performAction:error:"],
},
},
);
await build(
"napi",
["napi/fixture.c"],
[
...common,
"-bundle",
"-undefined",
"dynamic_lookup",
join(sourceRoot, "napi/fixture.c"),
"-o",
join(outputRoot, "fixture.node"),
],
{
expectations: {
symbols: ["_napi_register_module_v1"],
strings: ["reaProbeOne", "reaProbeTwo", "reaProbeThree"],
},
},
);
try {
const swiftc = (await exec("xcrun", ["--find", "swiftc"])).stdout.trim();
const swiftVersion = (await exec(swiftc, ["--version"])).stdout.split(
"\n",
)[0];
await build(
"swift",
["swift/fixture.swift"],
[
"-sdk",
sdkPath,
"-Onone",
"-g",
join(sourceRoot, "swift/fixture.swift"),
"-o",
join(outputRoot, "swift"),
],
{
expectations: {
strings: [
"REA_SWIFT_EXECUTE",
"REAService",
"REARecord",
"REAState",
"REAProtocol",
],
},
compiler: swiftc,
compilerVersion: swiftVersion,
},
);
} catch (cause) {
process.stderr.write(
`Skipping optional Swift fixture: ${cause instanceof Error ? cause.message : String(cause)}\n`,
);
}
const manifest = {
schemaVersion: 1,
platform: platform(),
architecture,
generatedAt: new Date(0).toISOString(),
fixtures,
};
await writeFile(
join(outputRoot, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
process.stdout.write(
`${JSON.stringify({ outputRoot, fixtureCount: fixtures.length })}\n`,
);
async function build(name, sources, args, options) {
const compiler = options.compiler ?? clang;
const compilerVersion = options.compilerVersion ?? clangVersion;
const normalized = await Promise.all(
sources.map(async (source) => {
if (typeof source !== "string") return source;
return {
path: source,
content: await readFile(join(sourceRoot, source), "utf8"),
};
}),
);
await exec(compiler, args, { maxBuffer: 10 * 1024 * 1024 });
const artifactPath = args.at(-1);
if (typeof artifactPath !== "string")
throw new Error(`Missing output for ${name}`);
const artifact = await readFile(artifactPath);
fixtures.push({
name,
artifact: relative(outputRoot, artifactPath),
artifactSha256: sha256(artifact),
sourceSha256: sourceDigest(normalized),
sources: normalized.map(({ path, content }) => ({
path,
sha256: sha256(content),
})),
compiler: { path: compiler, version: compilerVersion, arguments: args },
expectations: options.expectations,
});
}

View File

@ -0,0 +1,6 @@
export const LARGE_FIXTURE_COUNT: number;
export function sha256(value: string | Uint8Array): string;
export function generateLargeFixture(count?: number): string;
export function sourceDigest(
sources: readonly { readonly path: string; readonly content: string }[],
): string;

View File

@ -0,0 +1,30 @@
import { createHash } from "node:crypto";
export const LARGE_FIXTURE_COUNT = 1_205;
export const sha256 = (value) =>
createHash("sha256").update(value).digest("hex");
export const generateLargeFixture = (count = LARGE_FIXTURE_COUNT) => {
if (!Number.isInteger(count) || count < 1)
throw new Error("Fixture count must be a positive integer");
const declarations = Array.from(
{ length: count },
(_, index) =>
`__attribute__((noinline, used)) int rea_page_${String(index).padStart(4, "0")}(void) { puts("REA_PAGE_${String(index).padStart(4, "0")}"); return ${index}; }`,
);
return [
"#include <stdio.h>",
...declarations,
"int main(void) { return rea_page_0000(); }",
"",
].join("\n");
};
export const sourceDigest = (sources) => {
const canonical = [...sources]
.sort(({ path: left }, { path: right }) => left.localeCompare(right))
.map(({ path, content }) => `${path}\0${sha256(content)}\n`)
.join("");
return sha256(canonical);
};

View File

@ -1,7 +1,7 @@
#!/usr/bin/env node
// Route production MCP before importing Incur. Incur owns registration helpers
// such as `mcp add`, while only dist/main.js may serve the 42-tool stdio server.
// such as `mcp add`, while only dist/main.js may serve the 46-tool stdio server.
const args = process.argv.slice(2);
const isMcpMode =
args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp");

View File

@ -0,0 +1,68 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { sha256 } from "./lib/conformance-fixtures.mjs";
const exec = promisify(execFile);
const manifestPath = resolve(
process.argv[2] ?? "build/conformance/manifest.json",
);
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.fixtures))
throw new Error("Unsupported conformance manifest");
const root = dirname(manifestPath);
for (const fixture of manifest.fixtures) {
const artifactPath = join(root, fixture.artifact);
const artifact = await readFile(artifactPath);
if (sha256(artifact) !== fixture.artifactSha256)
throw new Error(`${fixture.name}: artifact hash mismatch`);
const fileOutput = (await exec("file", [artifactPath])).stdout;
if (!fileOutput.includes("Mach-O"))
throw new Error(`${fixture.name}: not Mach-O`);
const symbols = (
await exec("nm", ["-gU", artifactPath], { maxBuffer: 20 * 1024 * 1024 })
).stdout;
const strings = (
await exec("strings", [artifactPath], { maxBuffer: 20 * 1024 * 1024 })
).stdout;
for (const symbol of fixture.expectations.symbols ?? [])
if (!symbols.includes(symbol))
throw new Error(`${fixture.name}: missing symbol ${symbol}`);
for (const value of fixture.expectations.strings ?? [])
if (!strings.includes(value))
throw new Error(`${fixture.name}: missing string ${value}`);
verifyCount(
symbols,
fixture.expectations.symbolPrefix,
fixture.expectations.symbolCount,
fixture.name,
);
verifyCount(
strings,
fixture.expectations.stringPrefix,
fixture.expectations.stringCount,
fixture.name,
);
}
const v1 = manifest.fixtures.find(({ name }) => name === "version-v1");
const v2 = manifest.fixtures.find(({ name }) => name === "version-v2");
if (v1?.artifactSha256 === v2?.artifactSha256)
throw new Error("Version-pair artifacts unexpectedly have equal hashes");
process.stdout.write(
`${JSON.stringify({ verified: manifest.fixtures.length, manifestPath })}\n`,
);
function verifyCount(output, prefix, expected, fixture) {
if (prefix === undefined || expected === undefined) return;
const count = output
.split("\n")
.filter((line) => line.includes(prefix)).length;
if (count !== expected)
throw new Error(
`${fixture}: expected ${expected} ${prefix} records, received ${count}`,
);
}

View File

@ -161,8 +161,8 @@ try {
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");
if ((await client.listTools()).tools.length !== 46)
throw new Error("packaged MCP did not expose 46 tools");
const result = await client.callTool({
name: "current_document",
arguments: {},
@ -177,7 +177,7 @@ try {
}
process.stdout.write(
`${JSON.stringify({ cli: true, incurMcpCommand: "npx -y @morluto/rea mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 42, targetFree: true })}\n`,
`${JSON.stringify({ cli: true, incurMcpCommand: "npx -y @morluto/rea mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 46, targetFree: true })}\n`,
);
} finally {
if (tarball) await rm(join(root, tarball), { force: true });

View File

@ -25,6 +25,99 @@ const textValue = (result) => {
const jsonValue = (result) => JSON.parse(textValue(result));
const requireSuccessfulTool = (result, operation) => {
if (result.isError === true) {
throw new Error(`${operation} failed: ${textValue(result)}`);
}
const value = jsonValue(result);
if (value === null || typeof value !== "object" || !("result" in value)) {
throw new Error(`${operation} omitted its evidence result`);
}
return value.result;
};
const firstProcedureAddress = (result) => {
const items = result.structuredContent?.result?.items;
if (!Array.isArray(items) || items.length === 0) {
throw new Error("The verifier target contains no analyzed procedure");
}
const address = items[0]?.address;
if (typeof address !== "string" || !/^0x[0-9a-f]+$/iu.test(address)) {
throw new Error("list_procedures returned an invalid procedure address");
}
return address;
};
const requirePseudocode = (value, operation) => {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${operation} returned empty pseudocode`);
}
if (value === "No output" || value.startsWith("Error:")) {
throw new Error(`${operation} returned an embedded failure: ${value}`);
}
return value;
};
const requireFunctionDossier = (value, expectedAddress) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error("analyze_function returned no function dossier");
}
if (value.procedure?.address !== expectedAddress) {
throw new Error("analyze_function returned the wrong procedure");
}
if (
typeof value.procedure?.name !== "string" ||
value.procedure.name.trim().length === 0
) {
throw new Error("analyze_function omitted the procedure name");
}
requirePseudocode(value.pseudocode?.text, "analyze_function");
for (const field of [
"comments",
"callers",
"callees",
"incoming_references",
"basic_blocks",
]) {
const collection = value[field];
if (
collection === null ||
typeof collection !== "object" ||
!Array.isArray(collection.items) ||
!Number.isInteger(collection.total)
) {
throw new Error(`analyze_function returned an invalid ${field} result`);
}
}
return value;
};
const requireSafeDiagnostics = (chunks) => {
const lines = chunks
.join("")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
for (const line of lines) {
let diagnostic;
try {
diagnostic = JSON.parse(line);
} catch {
throw new Error(`The MCP runtime emitted malformed stderr: ${line}`);
}
if (
diagnostic === null ||
typeof diagnostic !== "object" ||
diagnostic.application !== "rea" ||
typeof diagnostic.level !== "number" ||
diagnostic.level >= 40
) {
throw new Error(`The MCP runtime emitted unsafe stderr: ${line}`);
}
}
return lines.length;
};
const targetPath = process.env.HOPPER_TARGET_PATH;
const secondTargetPath = process.env.HOPPER_SECOND_TARGET_PATH;
if (!targetPath || !secondTargetPath)
@ -49,8 +142,10 @@ const transport = new StdioClientTransport({
stderr: "pipe",
});
let stderrBytes = 0;
const stderrChunks = [];
transport.stderr?.on("data", (chunk) => {
stderrBytes += chunk.length;
if (stderrBytes <= 16_384) stderrChunks.push(chunk.toString("utf8"));
});
const client = new Client({ name: "real-hopper-verifier", version: "1.0.0" });
let summary;
@ -61,7 +156,7 @@ 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 42 tools");
throw new Error("The real server did not expose the intended 46 tools");
}
const options = { timeout };
@ -92,15 +187,73 @@ try {
{ name: "list_procedures", arguments: {} },
options,
);
const procedureMap = jsonValue(procedures);
const firstAddress = Object.keys(procedureMap)[0];
if (firstAddress === undefined) {
throw new Error("The verifier target contains no analyzed procedure");
requireSuccessfulTool(procedures, "list_procedures");
const firstAddress = firstProcedureAddress(procedures);
const containment = requireSuccessfulTool(
await client.callTool(
{
name: "resolve_containing_procedure",
arguments: { address: firstAddress },
},
options,
),
"resolve_containing_procedure",
);
if (
containment?.found !== true ||
containment.procedure?.address !== firstAddress
) {
throw new Error(
"resolve_containing_procedure returned the wrong procedure",
);
}
const references = requireSuccessfulTool(
await client.callTool(
{
name: "procedure_references",
arguments: {
procedure: firstAddress,
direction: "outgoing",
limit: 10,
max_instructions: 100,
},
},
options,
),
"procedure_references",
);
if (!Array.isArray(references?.references?.items)) {
throw new Error("procedure_references returned an invalid bounded result");
}
const bounded = await client.callTool(
{ name: "batch_decompile", arguments: { addresses: [firstAddress] } },
options,
);
const boundedResult = requireSuccessfulTool(bounded, "batch_decompile");
if (
boundedResult === null ||
typeof boundedResult !== "object" ||
Array.isArray(boundedResult)
) {
throw new Error("batch_decompile returned an invalid result");
}
const boundedPseudocode = requirePseudocode(
boundedResult[firstAddress],
"batch_decompile",
);
const dossier = requireFunctionDossier(
requireSuccessfulTool(
await client.callTool(
{
name: "analyze_function",
arguments: { procedure: firstAddress },
},
options,
),
"analyze_function",
),
firstAddress,
);
const switched = await client.callTool(
{ name: "open_binary", arguments: { path: targetB } },
options,
@ -127,9 +280,7 @@ try {
"Hopper's bundled MCP server was running during verification",
);
}
if (stderrBytes !== 0) {
throw new Error("The MCP runtime emitted stderr during verification");
}
const diagnosticCount = requireSafeDiagnostics(stderrChunks);
const closed = await client.callTool(
{ name: "close_binary", arguments: {} },
@ -144,9 +295,13 @@ try {
segmentCount: jsonValue(segments).length,
boundedTool: "batch_decompile",
boundedInputCount: 1,
boundedResultKeys: Object.keys(jsonValue(bounded)).length,
boundedResultKeys: Object.keys(boundedResult).length,
boundedPseudocodeChars: boundedPseudocode.length,
analyzedProcedure: dossier.procedure,
analyzedPseudocodeChars: dossier.pseudocode.text.length,
bundledMcpRunning,
stderrBytes,
diagnosticCount,
dynamicSession: true,
targets: [targetA, targetB],
switched: true,

View File

@ -0,0 +1,37 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { AnalysisError } from "../domain/errors.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Result } from "../domain/result.js";
interface ExecutionOptions {
readonly signal?: AbortSignal;
}
/** Provider-neutral application capability for one analysis operation. */
export interface AnalysisOperationPort {
execute(
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
): Promise<Result<JsonValue, AnalysisError>>;
}
/** Provider session bound to exactly one parsed artifact. */
export interface AnalysisClient extends AnalysisOperationPort {
close(): Promise<void>;
}
export type AnalysisClientFactory = (target: BinaryTarget) => AnalysisClient;
export interface ProviderIdentity {
readonly id: string;
readonly name: string;
readonly version: string | null;
}
/** Factory and capability declaration for an analysis implementation. */
export interface AnalysisProvider {
identity(): ProviderIdentity;
capabilities(): readonly string[];
createClient(target: BinaryTarget): AnalysisClient;
}

View File

@ -3,18 +3,30 @@ import { parseBinaryTarget } from "../domain/binaryTarget.js";
import {
HopperCancelledError,
NoBinaryOpenError,
type HopperError,
type AnalysisError,
} 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";
import type { JsonValue } from "../domain/jsonValue.js";
import type {
AnalysisClient,
AnalysisClientFactory,
AnalysisOperationPort,
AnalysisProvider,
} from "./AnalysisProvider.js";
/** A closeable Hopper port created for exactly one parsed target. */
export interface BinaryClient extends HopperToolPort {
close(): Promise<void>;
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
export interface BinarySessionPort extends AnalysisOperationPort {
open(
path: string,
options?: {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
},
): Promise<Result<BinaryTarget, AnalysisError>>;
close(): Promise<Result<null, AnalysisError>>;
status(): JsonValue;
activeTarget(): BinaryTarget | undefined;
}
/** Production seam for creating one unstarted client for a parsed target. */
export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
/**
* Owns the single active target shared by CLI and MCP adapters.
@ -24,13 +36,20 @@ export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
* down. A failed switch recreates the previous target instead of retaining a
* client whose bridge was already shut down.
*/
export class BinarySession implements HopperToolPort {
export class BinarySession implements BinarySessionPort {
#active:
| { readonly target: BinaryTarget; readonly client: BinaryClient }
| { readonly target: BinaryTarget; readonly client: AnalysisClient }
| undefined;
#transition: Promise<void> = Promise.resolve();
readonly #calls = new Set<Promise<unknown>>();
constructor(readonly createClient: BinaryClientFactory) {}
readonly #createClient: AnalysisClientFactory;
constructor(readonly provider: AnalysisProvider | AnalysisClientFactory) {
this.#createClient =
typeof provider === "function"
? provider
: (target) => provider.createClient(target);
}
/**
* Open or switch targets after draining calls against the current target.
@ -42,7 +61,7 @@ export class BinarySession implements HopperToolPort {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
} = {},
): Promise<Result<BinaryTarget, HopperError>> {
): Promise<Result<BinaryTarget, AnalysisError>> {
return this.#serialize(async () => {
if (isAborted(options.signal)) return err(new HopperCancelledError());
const parsed = await parseBinaryTarget(
@ -59,8 +78,8 @@ export class BinarySession implements HopperToolPort {
const previous = this.#active;
this.#active = undefined;
await previous?.client.close();
const client = this.createClient(parsed.value);
const started = await client.callTool("health", {}, options);
const client = this.#createClient(parsed.value);
const started = await client.execute("health", {}, options);
if (!started.ok) {
await client.close();
await this.#restore(previous);
@ -77,7 +96,7 @@ export class BinarySession implements HopperToolPort {
}
/** Close the active target, if any. */
close(): Promise<Result<null, HopperError>> {
close(): Promise<Result<null, AnalysisError>> {
return this.#serialize(async () => {
const previous = this.#active;
this.#active = undefined;
@ -95,26 +114,33 @@ export class BinarySession implements HopperToolPort {
: {
open: true,
path: target.path,
sha256: target.sha256,
format: target.format,
kind: target.kind,
architecture: target.architecture ?? null,
};
}
/** Return the immutable artifact identity captured before Hopper launched. */
activeTarget(): BinaryTarget | undefined {
return this.#active?.target;
}
/**
* Invoke a Hopper tool against the active target.
* Calls may overlap, but a pending target transition prevents new calls from
* entering until the transition has settled.
*/
async callTool(
async execute(
name: string,
arguments_: Readonly<Record<string, JsonValue>>,
options?: { readonly signal?: AbortSignal },
): Promise<Result<JsonValue, HopperError>> {
): Promise<Result<JsonValue, AnalysisError>> {
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);
const call = active.client.execute(name, arguments_, options);
this.#calls.add(call);
try {
return await call;
@ -138,12 +164,12 @@ export class BinarySession implements HopperToolPort {
async #restore(
previous:
| { readonly target: BinaryTarget; readonly client: BinaryClient }
| { readonly target: BinaryTarget; readonly client: AnalysisClient }
| undefined,
): Promise<void> {
if (previous === undefined) return;
const client = this.createClient(previous.target);
const started = await client.callTool("health", {});
const client = this.#createClient(previous.target);
const started = await client.execute("health", {});
if (started.ok) this.#active = { target: previous.target, client };
else await client.close();
}

View File

@ -1,7 +1,8 @@
import { parseConfig } from "../config.js";
import type { JsonValue } from "../hopper/protocol.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { createBinarySession } from "./runtime.js";
import { silentLogger, type Logger } from "../logger.js";
import { createEvidence } from "../domain/evidence.js";
/**
* Open one binary, execute one tool, and always release the bridge session.
@ -22,9 +23,13 @@ export const runDirectAnalysis = async (
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_);
const result = await session.execute(tool, arguments_);
return result.ok
? result.value
? createEvidence(opened.value, {
operation: tool,
parameters: arguments_,
result: result.value,
})
: { error: result.error._tag, message: result.error.message };
} finally {
await session.close();

View File

@ -1,12 +1,11 @@
import type { HopperToolPort } from "./HopperToolPort.js";
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import { HopperProtocolError, type HopperError } from "../domain/errors.js";
import { AnalysisProtocolError, type AnalysisError } from "../domain/errors.js";
import {
parseDocuments,
parseAddressedPage,
parseListCount,
parseNames,
parseProcedures,
parseRelatedAddresses,
parseSegments,
} from "../domain/hopperValues.js";
@ -17,9 +16,15 @@ import {
discoverObjcProtocols,
discoverSwiftClasses,
} from "../domain/symbolAnalysis.js";
import type { JsonValue } from "../hopper/protocol.js";
import type { JsonValue } from "../domain/jsonValue.js";
type EnhancedResult = Promise<Result<JsonValue, HopperError>>;
type EnhancedResult = Promise<Result<JsonValue, AnalysisError>>;
type TraceMatch = { type: string; address: string; value: string };
type TraceReference = {
target_address: string;
source_address: string;
containing_procedure: JsonValue;
};
/**
* Composes official Hopper operations into bounded reverse-engineering tools.
@ -27,7 +32,7 @@ type EnhancedResult = Promise<Result<JsonValue, HopperError>>;
* validates them, allowing direct callers to use the same service safely.
*/
export class EnhancedTools {
constructor(private readonly hopper: HopperToolPort) {}
constructor(private readonly analysis: AnalysisOperationPort) {}
/** Parse inputs again at the direct-call boundary, then dispatch exhaustively. */
execute(
@ -70,31 +75,41 @@ export class EnhancedTools {
? this.#findXrefs(parsed.data.name, signal)
: invalidInput(name, parsed.error);
}
case "binary_overview":
return this.#binaryOverview(signal);
case "binary_overview": {
const parsed = enhancedInputSchemas.binary_overview.safeParse(input);
return parsed.success
? this.#binaryOverview(parsed.data, signal)
: invalidInput(name, parsed.error);
}
case "analyze_function": {
const parsed = enhancedInputSchemas.analyze_function.safeParse(input);
return parsed.success
? this.#call("analyze_function", parsed.data, signal)
: invalidInput(name, parsed.error);
}
case "trace_feature": {
const parsed = enhancedInputSchemas.trace_feature.safeParse(input);
return parsed.success
? this.#traceFeature(parsed.data, signal)
: invalidInput(name, parsed.error);
}
}
}
async #swiftClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
const result = await this.#call("list_procedures", {}, signal);
if (!result.ok) return result;
const procedures = parseProcedures(result.value);
const procedures = await this.#allProcedures(signal);
return procedures.ok
? ok(discoverSwiftClasses(procedures.value, pattern))
: procedures;
}
async #objcClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
const result = await this.#call("list_names", {}, signal);
if (!result.ok) return result;
const names = parseNames(result.value);
const names = await this.#allAddressed("list_names", signal);
return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names;
}
async #objcProtocols(signal?: AbortSignal): EnhancedResult {
const result = await this.#call("list_names", {}, signal);
if (!result.ok) return result;
const names = parseNames(result.value);
const names = await this.#allAddressed("list_names", signal);
return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
}
@ -187,9 +202,7 @@ export class EnhancedTools {
}
async #analyzeSwiftTypes(signal?: AbortSignal): EnhancedResult {
const result = await this.#call("list_procedures", {}, signal);
if (!result.ok) return result;
const procedures = parseProcedures(result.value);
const procedures = await this.#allProcedures(signal);
return procedures.ok
? ok(categorizeSwiftTypes(procedures.value))
: procedures;
@ -213,45 +226,230 @@ export class EnhancedTools {
);
}
async #binaryOverview(signal?: AbortSignal): EnhancedResult {
const [segmentsResult, documentsResult, proceduresResult, stringsResult] =
async #binaryOverview(
input: { detail: "concise" | "detailed"; limit: number },
signal?: AbortSignal,
): EnhancedResult {
const [segmentsResult, documentsResult, procedures, stringsResult] =
await Promise.all([
this.#call("list_segments", {}, signal),
this.#call("list_documents", {}, signal),
this.#call("list_procedures", {}, signal),
this.#allProcedures(signal),
this.#call("list_strings", {}, signal),
]);
if (!segmentsResult.ok) return segmentsResult;
if (!documentsResult.ok) return documentsResult;
if (!proceduresResult.ok) return proceduresResult;
if (!procedures.ok) return procedures;
if (!stringsResult.ok) return stringsResult;
const segments = parseSegments(segmentsResult.value);
if (!segments.ok) return segments;
const documents = parseDocuments(documentsResult.value);
if (!documents.ok) return documents;
const procedures = parseProcedures(proceduresResult.value);
if (!procedures.ok) return procedures;
const stringCount = parseListCount(stringsResult.value, "strings");
if (!stringCount.ok) return stringCount;
return ok({
document: documents.value[0] ?? "unknown",
detail: input.detail,
segments: segments.value
.slice(0, 10)
.map(({ name, start, end }) => ({ name, start, end })),
.slice(0, input.limit)
.map(({ name, start, end }) =>
input.detail === "detailed"
? { name, start, end, length: addressDistance(start, end) }
: { name, start, end },
),
segment_count: segments.value.length,
procedure_count: procedures.value.length,
string_count: stringCount.value,
});
}
async #traceFeature(
input: {
readonly query: string;
readonly case_sensitive: boolean;
readonly limit: number;
readonly max_operations: number;
},
signal?: AbortSignal,
): EnhancedResult {
const searched = await this.#literalMatches(input, signal);
if (!searched.ok) return searched;
const residual = new Set<string>();
const traced = await this.#traceReferences(
searched.value.matches,
input.limit,
input.max_operations - searched.value.operations,
signal,
);
if (!traced.ok) return traced;
for (const reason of traced.value.residual) residual.add(reason);
const operations = searched.value.operations + traced.value.operations;
if (operations >= input.max_operations)
residual.add("Investigation reached the operation budget.");
if (searched.value.matches.length >= input.limit)
residual.add("Literal matches reached the configured limit.");
return ok({
query: input.query,
search_mode: "literal",
operations_used: operations,
operation_budget: input.max_operations,
matches: searched.value.matches,
references: traced.value.references,
truncated: residual.size > 0,
residual_unknowns: [...residual],
});
}
async #literalMatches(
input: {
readonly query: string;
readonly case_sensitive: boolean;
readonly limit: number;
readonly max_operations: number;
},
signal?: AbortSignal,
): Promise<
Result<{ matches: TraceMatch[]; operations: number }, AnalysisError>
> {
let operations = 0;
const matches: TraceMatch[] = [];
const needle = input.case_sensitive
? input.query
: input.query.toLowerCase();
for (const [tool, type] of [
["list_strings", "string"],
["list_procedures", "procedure"],
] as const) {
let offset = 0;
while (
operations < input.max_operations &&
matches.length < input.limit
) {
operations += 1;
const result = await this.#call(tool, { offset, limit: 500 }, signal);
if (!result.ok) return result;
const page = parseAddressedPage(result.value);
if (!page.ok) return page;
for (const item of page.value.items) {
const haystack = input.case_sensitive
? item.name
: item.name.toLowerCase();
if (haystack.includes(needle))
matches.push({ type, address: item.address, value: item.name });
if (matches.length >= input.limit) break;
}
if (!page.value.hasMore || page.value.nextOffset === null) break;
offset = page.value.nextOffset;
}
}
return ok({ matches, operations });
}
async #traceReferences(
matches: readonly TraceMatch[],
limit: number,
operationBudget: number,
signal?: AbortSignal,
): Promise<
Result<
{ references: TraceReference[]; operations: number; residual: string[] },
AnalysisError
>
> {
let operations = 0;
const references: TraceReference[] = [];
const residual = new Set<string>();
for (const match of matches) {
if (operations >= operationBudget) {
residual.add("Reference traversal stopped at the operation budget.");
break;
}
operations += 1;
const xrefs = await this.#call(
"xrefs",
{ address: match.address },
signal,
);
if (!xrefs.ok) return xrefs;
if (!Array.isArray(xrefs.value))
return err(
new AnalysisProtocolError("xrefs returned a non-array result"),
);
for (const source of xrefs.value) {
if (typeof source !== "string")
return err(
new AnalysisProtocolError("xrefs returned a non-address value"),
);
if (operations >= operationBudget) {
residual.add(
"Containing-procedure resolution stopped at the operation budget.",
);
break;
}
operations += 1;
const resolved = await this.#call(
"resolve_containing_procedure",
{ address: source },
signal,
);
if (!resolved.ok) return resolved;
references.push({
target_address: match.address,
source_address: source,
containing_procedure: resolved.value,
});
if (references.length >= limit) {
residual.add("Reference results reached the configured limit.");
break;
}
}
if (references.length >= limit) break;
}
return ok({
references,
operations,
residual: [...residual],
});
}
async #allProcedures(signal?: AbortSignal) {
return this.#allAddressed("list_procedures", signal);
}
async #allAddressed(
tool: "list_names" | "list_procedures",
signal?: AbortSignal,
) {
const entries: Array<{ address: string; name: string }> = [];
let offset = 0;
while (true) {
const result = await this.#call(tool, { offset, limit: 500 }, signal);
if (!result.ok) return result;
const page = parseAddressedPage(result.value);
if (!page.ok) return page;
entries.push(...page.value.items);
if (!page.value.hasMore || page.value.nextOffset === null) {
return ok(entries);
}
if (page.value.nextOffset <= offset) {
return err(
new AnalysisProtocolError(
`${tool} returned a non-advancing pagination offset`,
),
);
}
offset = page.value.nextOffset;
}
}
#call(
name: string,
arguments_: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
): Promise<Result<JsonValue, HopperError>> {
return this.hopper.callTool(
): Promise<Result<JsonValue, AnalysisError>> {
return this.analysis.execute(
name,
arguments_,
signal === undefined ? {} : { signal },
@ -262,7 +460,7 @@ export class EnhancedTools {
const invalidInput = (name: EnhancedToolName, cause: Error): EnhancedResult =>
Promise.resolve(
err(
new HopperProtocolError(`Invalid ${name} input after MCP validation`, {
new AnalysisProtocolError(`Invalid ${name} input after MCP validation`, {
cause,
}),
),
@ -278,3 +476,6 @@ const resolveAddress = (value: JsonValue): string | undefined => {
? candidate
: undefined;
};
const addressDistance = (start: string, end: string): number =>
Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));

View File

@ -1,17 +0,0 @@
import type { HopperError } from "../domain/errors.js";
import type { Result } from "../domain/result.js";
import type { JsonValue } from "../hopper/protocol.js";
/** Application-owned capability for invoking one official Hopper tool. */
export interface HopperToolPort {
/**
* Invoke a declared bridge operation with JSON-safe arguments.
* Implementations return expected Hopper, timeout, and cancellation failures
* as values and must honor an already-aborted signal.
*/
callTool(
name: string,
arguments_: Readonly<Record<string, JsonValue>>,
options?: { readonly signal?: AbortSignal },
): Promise<Result<JsonValue, HopperError>>;
}

View File

@ -1,9 +1,6 @@
import { fileURLToPath } from "node:url";
import type { AppConfig } from "../config.js";
import { BinarySession } from "./BinarySession.js";
import { HopperApplicationLauncher } from "../hopper/BridgeLauncher.js";
import { HopperClient } from "../hopper/HopperClient.js";
import { HopperProvider } from "../hopper/HopperProvider.js";
import { silentLogger, type Logger } from "../logger.js";
/**
@ -15,23 +12,5 @@ export const createBinarySession = (
config: AppConfig,
logger: Logger = silentLogger,
): BinarySession => {
const bridgeScriptPath = fileURLToPath(
new URL("../../bridge/hopper_bridge.py", import.meta.url),
);
return new BinarySession(
(target) =>
new HopperClient({
launcher: new HopperApplicationLauncher({
launcherPath: config.hopperLauncherPath,
targetPath: target.path,
targetKind: target.kind,
loaderArgs:
config.hopperLoaderArgs.length > 0
? config.hopperLoaderArgs
: target.loaderArgs,
bridgeScriptPath,
}),
logger: logger.child({ layer: "bridge" }),
}),
);
return new BinarySession(new HopperProvider(config, logger));
};

View File

@ -18,7 +18,23 @@ export const enhancedInputSchemas = {
}),
analyze_swift_types: z.object({}),
find_xrefs_to_name: z.object({ name: z.string() }),
binary_overview: z.object({}),
binary_overview: z.object({
detail: z.enum(["concise", "detailed"]).default("concise"),
limit: z.number().int().min(1).max(50).default(10),
}),
analyze_function: z.object({
procedure: z.string().describe("A procedure name or address"),
include_assembly: z.boolean().default(false),
limit: z.number().int().min(1).max(500).default(100),
max_pseudocode_chars: z.number().int().min(1).max(100_000).default(20_000),
max_instructions: z.number().int().min(1).max(5_000).default(500),
}),
trace_feature: z.object({
query: z.string().min(1),
case_sensitive: z.boolean().default(false),
limit: z.number().int().min(1).max(100).default(20),
max_operations: z.number().int().min(1).max(100).default(20),
}),
} as const;
export type EnhancedToolName = keyof typeof enhancedInputSchemas;
@ -33,4 +49,6 @@ export const enhancedToolNameSchema = z.enum([
"analyze_swift_types",
"find_xrefs_to_name",
"binary_overview",
"analyze_function",
"trace_feature",
]);

View File

@ -1,6 +1,11 @@
import { z } from "zod";
import { enhancedInputSchemas } from "./enhancedInputs.js";
import {
enhancedOutputSchemas,
officialOutputSchemas,
sessionOutputSchemas,
} from "./toolOutputSchemas.js";
const document = z.string().optional().describe("The document name");
const address = z.string().describe("A Hopper address");
@ -11,6 +16,17 @@ const caseSensitive = z
.boolean()
.default(false)
.describe("Whether to match case");
const pagination = {
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(500).default(100),
};
export interface ToolAnnotations {
readonly readOnlyHint: boolean;
readonly destructiveHint: boolean;
readonly idempotentHint: true;
readonly openWorldHint: false;
}
/** Adapter family responsible for implementing a public MCP tool. */
type ToolKind = "official-proxy" | "enhanced" | "session";
@ -21,161 +37,248 @@ export interface ToolContract {
readonly description: string;
readonly kind: ToolKind;
readonly inputSchema: z.ZodObject;
readonly outputSchema: z.ZodObject;
readonly annotations: ToolAnnotations;
}
const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({
readOnlyHint:
kind === "enhanced" ||
(!name.startsWith("set_") &&
name !== "unset_bookmark" &&
name !== "goto_address" &&
kind !== "session"),
destructiveHint:
name === "unset_bookmark" ||
name === "set_address_name" ||
name === "set_addresses_names" ||
name === "set_comment" ||
name === "set_inline_comment",
idempotentHint: true,
openWorldHint: false,
});
const official = (
name: string,
description: string,
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "official-proxy", inputSchema });
): ToolContract => ({
name,
description,
kind: "official-proxy",
inputSchema,
outputSchema: requireOutputSchema(officialOutputSchemas, name),
annotations: annotations(name, "official-proxy"),
});
const enhanced = (
name: string,
description: string,
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "enhanced", inputSchema });
): ToolContract => ({
name,
description,
kind: "enhanced",
inputSchema,
outputSchema: requireOutputSchema(enhancedOutputSchemas, name),
annotations: annotations(name, "enhanced"),
});
const session = (
name: string,
description: string,
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "session", inputSchema });
): ToolContract => ({
name,
description,
kind: "session",
inputSchema,
outputSchema: requireOutputSchema(sessionOutputSchemas, name),
annotations: annotations(name, "session"),
});
const requireOutputSchema = (
schemas: Readonly<Record<string, z.ZodObject>>,
name: string,
): z.ZodObject => {
const schema = schemas[name];
if (schema === undefined)
throw new Error(`Missing output schema for ${name}`);
return schema;
};
/** Bridge operations exposed without additional application composition. */
export const OFFICIAL_TOOL_CONTRACTS = [
official(
"address_name",
"Get the name at an address",
"Resolve the analyzed name at a code or data address, defaulting to Hopper's current cursor. Use before following a symbol into xrefs; null means Hopper has no name at that address.",
z.object({ document, address: optionalAddress }),
),
official(
"comment",
"Get the comment at an address",
"Read the regular analysis comment at an address, defaulting to the current cursor. This is read-only and returns null when no comment exists; use set_comment to persist a finding.",
z.object({ document, address: optionalAddress }),
),
official(
"current_address",
"Get the current address",
"Return Hopper's current cursor address for the selected document. Use only for interactive navigation state; prefer explicit addresses in reproducible investigations.",
z.object({ document }),
),
official(
"current_procedure",
"Get the current procedure",
"Return the analyzed procedure containing Hopper's current cursor. Use procedure_info or analyze_function next; this depends on GUI cursor state and is not an exhaustive lookup.",
z.object({ document }),
),
official("current_document", "Get the current document", z.object({})),
official(
"current_document",
"Return the document currently selected by REA's Hopper bridge. Use list_documents before switching when several documents are open.",
z.object({}),
),
official(
"goto_address",
"Navigate to an address",
"Move Hopper's GUI cursor to a hexadecimal address and return the resolved address. This changes navigation state but not analysis data; use explicit-address tools for headless workflows.",
z.object({ address, document }),
),
official(
"inline_comment",
"Get the inline comment at an address",
"Read the inline instruction comment at an address, defaulting to the current cursor. Returns null when absent; use set_inline_comment to write one.",
z.object({ document, address: optionalAddress }),
),
official("list_bookmarks", "List bookmarks", z.object({ document })),
official("list_documents", "List open documents", z.object({})),
official(
"list_bookmarks",
"List every bookmark in the selected Hopper document as address and name pairs. Use bookmarks as analyst-authored navigation aids; this does not discover code references.",
z.object({ document }),
),
official(
"list_documents",
"List all Hopper documents visible to the bridge. Use before set_current_document when a provider session contains multiple documents.",
z.object({}),
),
official(
"list_names",
"List names",
z.object({ document, address: optionalAddress }),
"Page through analyzed names as address/value pairs. Results are bounded by offset and limit; follow next_offset until has_more is false before claiming exhaustive symbol coverage.",
z.object({ document, address: optionalAddress, ...pagination }),
),
official(
"list_procedures",
"Page through analyzed procedures as address/value pairs. Wait for Hopper analysis first, then follow next_offset for exhaustive coverage; use returned addresses with analyze_function or procedure_pseudo_code.",
z.object({ document, ...pagination }),
),
official(
"list_segments",
"List all segments and sections in the selected document. Hopper's public API does not expose permissions, so writable and executable are null with explicit capability metadata.",
z.object({ document }),
),
official("list_procedures", "List procedures", z.object({ document })),
official("list_segments", "List segments", z.object({ document })),
official(
"list_strings",
"List strings",
z.object({ document, address: optionalAddress }),
"Page through analyzed strings, or filter to one address, as address/value pairs. Follow next_offset for exhaustive results and use xrefs on interesting string addresses.",
z.object({ document, address: optionalAddress, ...pagination }),
),
official(
"next_address",
"Get the next address",
"Return the next analyzed object address after an explicit address or current cursor. This is a navigation primitive, not instruction-flow or CFG analysis.",
z.object({ document, address: optionalAddress }),
),
official(
"prev_address",
"Get the previous address",
"Return the previous analyzed instruction start before an explicit address or current cursor. This is a navigation primitive and may fail at document boundaries.",
z.object({ document, address: optionalAddress }),
),
official(
"procedure_address",
"Resolve a procedure address",
"Resolve a procedure symbol name or hexadecimal address to its entry address. Use this to canonicalize user-supplied identifiers before xrefs, assembly, or decompilation.",
z.object({ procedure, document }),
),
official(
"procedure_assembly",
"Get procedure assembly",
"Return assembly for one analyzed procedure identified by symbol or hexadecimal address. Use when pseudocode loses calling-convention or instruction-level detail; output is currently returned as one unpaginated string.",
z.object({ procedure, document }),
),
official(
"procedure_callees",
"List procedure callees",
"Return Hopper's analyzed direct callees for one procedure identified by symbol or address. Indirect calls may be absent; use analyze_function and xrefs to corroborate the call path.",
z.object({ procedure, document }),
),
official(
"procedure_callers",
"List procedure callers",
"Return Hopper's analyzed direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit indirect references.",
z.object({ procedure, document }),
),
official(
"procedure_info",
"Get procedure metadata",
"Return bounded metadata for one procedure identified by symbol or address: entrypoint, signature, locals, size, and block count. Follow with decompilation or assembly for behavior.",
z.object({ procedure, document }),
),
official(
"procedure_references",
"Return a bounded set of raw incoming or outgoing reference edges for one procedure. Endpoint procedures are resolved only when Hopper reports containment; reference kinds remain explicitly unavailable.",
z.object({
procedure,
direction: z.enum(["incoming", "outgoing"]).default("outgoing"),
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(500).default(100),
max_instructions: z.number().int().min(1).max(5000).default(500),
document,
}),
),
official(
"procedure_pseudo_code",
"Decompile a procedure",
"Decompile one analyzed procedure by symbol name or hexadecimal address. Returns Hopper pseudocode, not original source, and may return null; request procedure_assembly when instruction precision matters.",
z.object({ procedure, document }),
),
official(
"resolve_containing_procedure",
"Resolve an arbitrary address, including an xref source or interior instruction, to its Hopper-analyzed containing procedure. A negative result includes an explicit reason and is not guessed from nearby symbols.",
z.object({ address, document }),
),
official(
"search_procedures",
"Search procedure names",
"Regex-search all analyzed procedure names with optional case sensitivity. The current bridge returns an unpaginated address/name map, so constrain patterns and use list_procedures for controlled exhaustive traversal.",
z.object({ pattern, case_sensitive: caseSensitive, document }),
),
official(
"search_strings",
"Search strings",
"Regex-search all analyzed strings with optional case sensitivity. The current bridge is unpaginated and evaluates Python regex, so use narrow patterns and follow matches with xrefs.",
z.object({ pattern, case_sensitive: caseSensitive, document }),
),
official(
"set_address_name",
"Set an address name",
"Assign an analyst name to one hexadecimal address and report Hopper's boolean result. This mutates analysis metadata; read it back with address_name before relying on it.",
z.object({ address, name: z.string(), document }),
),
official(
"set_addresses_names",
"Set multiple address names",
"Assign analyst names to an address/name map and return per-address success booleans. This mutates analysis metadata; use for bounded batches and verify failures individually.",
z.object({ names: z.record(z.string(), z.string()), document }),
),
official(
"set_bookmark",
"Set a bookmark",
"Create or replace a bookmark at a hexadecimal address. This mutates navigation metadata; verify with list_bookmarks and do not treat bookmarks as binary evidence.",
z.object({ address, name: z.string().optional(), document }),
),
official(
"set_comment",
"Set an address comment",
"Write a regular analysis comment at a hexadecimal address and return whether readback matched. This mutates the Hopper document; use comments to record evidence IDs or reasoning.",
z.object({ address, comment: z.string(), document }),
),
official(
"set_current_document",
"Select the current document",
"Select an already-open Hopper document by exact document name. This changes subsequent default-document routing; call list_documents first and prefer explicit document inputs where reproducibility matters.",
z.object({ document: z.string() }),
),
official(
"set_inline_comment",
"Set an inline comment",
"Write an inline instruction comment at a hexadecimal address and return whether readback matched. This mutates analysis metadata; confirm with inline_comment.",
z.object({ address, comment: z.string(), document }),
),
official(
"unset_bookmark",
"Remove a bookmark",
"Remove the bookmark at a hexadecimal address and return whether it is absent. This mutates navigation metadata and does not alter binary bytes.",
z.object({ address, document }),
),
official(
"xrefs",
"Get cross-references to an address",
"Return analyzed references to a code or data address, defaulting to the current cursor. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete.",
z.object({ document, address: optionalAddress }),
),
] as const satisfies readonly ToolContract[];
@ -184,60 +287,78 @@ export const OFFICIAL_TOOL_CONTRACTS = [
export const ENHANCED_TOOL_CONTRACTS = [
enhanced(
"swift_classes",
"Discover Swift classes",
"Discover legacy-mangled Swift class procedures after exhaustively paging analyzed procedures. Returns at most 100 entries and scans at most 5,000 symbols; use analyze_swift_types for other Swift kinds.",
enhancedInputSchemas.swift_classes,
),
enhanced(
"get_objc_classes",
"Discover Objective-C classes",
"Discover and deduplicate Objective-C class labels after exhaustively paging names, optionally filtering by literal substring. Returns at most 100 classes; inspect matching metadata and references next.",
enhancedInputSchemas.get_objc_classes,
),
enhanced(
"get_objc_protocols",
"Discover Objective-C protocols",
"Discover and deduplicate Objective-C and Swift protocol labels after exhaustively paging names. Returns at most 100 entries; use xrefs or analyze_function to connect a protocol to implementations.",
enhancedInputSchemas.get_objc_protocols,
),
enhanced(
"batch_decompile",
"Decompile up to 20 procedures",
"Decompile up to 20 explicit procedure symbols or addresses concurrently. Per-item strings may contain errors or no-output markers, so validate each result; use analyze_function for a richer single-function dossier.",
enhancedInputSchemas.batch_decompile,
),
enhanced(
"get_call_graph",
"Traverse a bounded call graph",
"Traverse Hopper's caller or callee relationships from one symbol or address for at most five levels. Nodes preserve per-procedure errors; indirect calls may be missing and results are not a whole-program CFG.",
enhancedInputSchemas.get_call_graph,
),
enhanced(
"analyze_swift_types",
"Categorize Swift mangled names",
"Categorize exhaustively paged procedure names into Swift classes, structs, enums, protocols, extensions, and other symbols. Scans at most 5,000 names and returns at most 50 entries per category.",
enhancedInputSchemas.analyze_swift_types,
),
enhanced(
"find_xrefs_to_name",
"Find cross-references by name",
"Resolve a name through Hopper and return analyzed references to its address. Use when starting from a selector or symbol; resolution failure is returned explicitly and xrefs remain untyped.",
enhancedInputSchemas.find_xrefs_to_name,
),
enhanced(
"binary_overview",
"Summarize the loaded binary",
"Use immediately after opening a target to summarize document, exhaustive procedure/string counts, and a bounded segment sample. detail controls segment fields and limit controls only the returned segment sample.",
enhancedInputSchemas.binary_overview,
),
enhanced(
"analyze_function",
"Preferred bounded analysis for one procedure symbol or address. Returns identity, pseudocode, optional assembly, comments, calls, incoming references, and blocks; unsupported outgoing references and CFG edges carry explicit unavailable metadata.",
enhancedInputSchemas.analyze_function,
),
enhanced(
"trace_feature",
"Trace a bounded literal feature query through matching strings and procedures, xrefs, and truthful containing-procedure resolution. Returns the operation budget, truncation, and residual unknowns; it does not infer reference kinds.",
enhancedInputSchemas.trace_feature,
),
] as const satisfies readonly ToolContract[];
/** Target lifecycle tools available only on the long-lived MCP adapter. */
export const SESSION_TOOL_CONTRACTS = [
session(
"open_binary",
"Open or switch the active binary",
"Open a local executable, application bundle, or Hopper database, replacing the active target only after validation. This launches Hopper and may show UI; call binary_overview after success.",
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({})),
session(
"close_binary",
"Close the active Hopper-backed target and release its provider process. The operation is idempotent; call binary_session to verify the session is closed.",
z.object({}),
),
session(
"binary_session",
"Report whether a target is open and, when open, its canonical path, format, and kind. Use before analysis calls or target switches; this performs no analysis.",
z.object({}),
),
] as const satisfies readonly ToolContract[];
/**
* Complete ordered public inventory used by registration and verification.
* Keep this collection at 42 tools unless a deliberate contract change updates
* Keep this collection at 46 tools unless a deliberate contract change updates
* snapshots, package verification, and real-Hopper verification together.
*/
export const TOOL_CONTRACTS = [

View File

@ -0,0 +1,287 @@
import { z } from "zod";
const evidenceMetadata = {
schema_version: z.literal(1),
artifact: z
.object({
path: z.string(),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
})
.nullable(),
provider: z.object({ id: z.literal("hopper"), version: z.null() }),
operation: z.string().min(1),
parameters: z.record(z.string(), z.json()),
confidence: z.literal("observed"),
limitations: z.array(z.string()),
};
const resultOf = (schema: z.ZodType) =>
z.object({ ...evidenceMetadata, result: schema });
const lifecycleResultOf = (schema: z.ZodType) => z.object({ result: schema });
const nullableText = z.string().nullable();
const addressList = z.array(z.string());
const addressedEntry = z.object({ address: z.string(), name: z.string() });
const procedureIdentity = z.object({ address: z.string(), name: z.string() });
const containingProcedureResolution = z.discriminatedUnion("found", [
z.object({
query_address: z.string(),
found: z.literal(true),
procedure: procedureIdentity,
}),
z.object({
query_address: z.string(),
found: z.literal(false),
procedure: z.null(),
reason: z.enum(["outside_segments", "not_in_procedure"]),
}),
]);
const unavailable = z.object({
available: z.literal(false),
reason: z.string(),
});
const bounded = (item: z.ZodType) =>
z.object({
items: z.array(item),
total: z.number().int().min(0).nullable(),
returned: z.number().int().min(0),
truncated: z.boolean(),
next_offset: z.number().int().min(0).nullable(),
});
const pageOutput = z.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
offset: z.number().int().min(0),
limit: z.number().int().min(1),
total: z.number().int().min(0),
next_offset: z.number().int().min(0).nullable(),
has_more: z.boolean(),
});
const segmentOutput = resultOf(
z.array(
z.object({
name: z.string(),
start: z.string(),
end: z.string(),
writable: z.boolean().nullable(),
executable: z.boolean().nullable(),
permissions: unavailable,
sections: z.array(
z.object({ name: z.string(), start: z.string(), end: z.string() }),
),
}),
),
);
const procedureInfoOutput = resultOf(
z.object({
name: z.string(),
entrypoint: z.string(),
basicblock_count: z.number().int().min(0),
length: z.number().min(0),
signature: nullableText,
locals: z.array(z.json()),
}),
);
const symbolDiscoveryOutput = (property: "classes" | "protocols") =>
resultOf(
z.object({
count: z.number().int().min(0),
[property]: z.array(addressedEntry),
}),
);
const graphNode = z.union([
z.object({ address: z.string(), calls: z.array(z.string()) }),
z.object({ address: z.string(), error: z.string() }),
]);
const unavailableOrBoundedStrings = z.union([unavailable, bounded(z.string())]);
const functionDossierOutput = resultOf(
z.object({
procedure: z.object({
address: z.string(),
name: z.string(),
signature: nullableText,
locals: z.array(z.json()),
}),
pseudocode: z.object({
text: z.string(),
total_chars: z.number().int().min(0),
returned_chars: z.number().int().min(0),
truncated: z.boolean(),
next_offset: z.number().int().min(0).nullable(),
}),
assembly: bounded(z.string()),
comments: bounded(
z.object({
address: z.string(),
kind: z.enum(["comment", "inline"]),
text: z.string(),
}),
),
callers: bounded(z.string()),
callees: bounded(z.string()),
incoming_references: bounded(z.string()),
referenced_strings: unavailableOrBoundedStrings,
referenced_names: unavailableOrBoundedStrings,
basic_blocks: bounded(
z.object({
start: z.string(),
end: z.string(),
successors: z.union([unavailable, z.array(z.string())]),
}),
),
}),
);
/** Exact structured-content schemas for the direct Hopper operations. */
export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
address_name: resultOf(nullableText),
comment: resultOf(nullableText),
current_address: resultOf(z.string()),
current_procedure: resultOf(z.string()),
current_document: resultOf(z.string()),
goto_address: resultOf(z.string()),
inline_comment: resultOf(nullableText),
list_bookmarks: resultOf(z.array(addressedEntry)),
list_documents: resultOf(z.array(z.string())),
list_names: resultOf(pageOutput),
list_procedures: resultOf(pageOutput),
list_segments: segmentOutput,
list_strings: resultOf(pageOutput),
next_address: resultOf(z.string()),
prev_address: resultOf(z.string()),
procedure_address: resultOf(z.string()),
procedure_assembly: resultOf(z.string()),
procedure_callees: resultOf(addressList),
procedure_callers: resultOf(addressList),
procedure_info: procedureInfoOutput,
procedure_references: resultOf(
z.object({
procedure: procedureIdentity,
direction: z.enum(["incoming", "outgoing"]),
references: bounded(
z.object({
source_address: z.string(),
target_address: z.string(),
source_procedure: procedureIdentity.nullable(),
target_procedure: procedureIdentity.nullable(),
kind: unavailable,
}),
),
instructions_scanned: z.number().int().min(0),
instruction_scan_truncated: z.boolean(),
}),
),
procedure_pseudo_code: resultOf(nullableText),
resolve_containing_procedure: resultOf(containingProcedureResolution),
search_procedures: resultOf(z.record(z.string(), z.string())),
search_strings: resultOf(z.record(z.string(), z.string())),
set_address_name: resultOf(z.boolean()),
set_addresses_names: resultOf(z.record(z.string(), z.boolean())),
set_bookmark: resultOf(z.boolean()),
set_comment: resultOf(z.boolean()),
set_current_document: resultOf(z.string()),
set_inline_comment: resultOf(z.boolean()),
unset_bookmark: resultOf(z.boolean()),
xrefs: resultOf(addressList),
};
/** Exact structured-content schemas for composed analysis workflows. */
export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
swift_classes: symbolDiscoveryOutput("classes"),
get_objc_classes: symbolDiscoveryOutput("classes"),
get_objc_protocols: symbolDiscoveryOutput("protocols"),
batch_decompile: resultOf(
z.union([
z.object({ error: z.literal("No addresses provided") }),
z.record(z.string(), z.string()),
]),
),
get_call_graph: resultOf(z.record(z.string(), z.array(graphNode))),
analyze_swift_types: resultOf(
z.object({
total: z.number().int().min(0),
categories: z.record(
z.string(),
z.object({
count: z.number().int().min(0),
items: z.array(addressedEntry),
}),
),
}),
),
find_xrefs_to_name: resultOf(
z.union([
z.object({ xrefs: addressList }),
z.object({ error: z.string() }),
]),
),
binary_overview: resultOf(
z.object({
document: z.string(),
detail: z.enum(["concise", "detailed"]),
segments: z.array(
z.object({
name: z.string(),
start: z.string(),
end: z.string(),
length: z.number().min(0).optional(),
}),
),
segment_count: z.number().int().min(0),
procedure_count: z.number().int().min(0),
string_count: z.number().int().min(0),
}),
),
analyze_function: functionDossierOutput,
trace_feature: resultOf(
z.object({
query: z.string(),
search_mode: z.literal("literal"),
operations_used: z.number().int().min(0),
operation_budget: z.number().int().min(1),
matches: z.array(
z.object({
type: z.enum(["string", "procedure"]),
address: z.string(),
value: z.string(),
}),
),
references: z.array(
z.object({
target_address: z.string(),
source_address: z.string(),
containing_procedure: containingProcedureResolution,
}),
),
truncated: z.boolean(),
residual_unknowns: z.array(z.string()),
}),
),
};
/** Exact structured-content schemas for target lifecycle operations. */
export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
open_binary: lifecycleResultOf(
z.object({
path: z.string(),
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
kind: z.enum(["executable", "database"]),
loaderArgs: z.array(z.string()),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
}),
),
close_binary: lifecycleResultOf(z.null()),
binary_session: lifecycleResultOf(
z.union([
z.object({ open: z.literal(false) }),
z.object({
open: z.literal(true),
path: z.string(),
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
kind: z.enum(["executable", "database"]),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
}),
]),
),
};

View File

@ -1,4 +1,6 @@
import { constants } from "node:fs";
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
import {
access,
open,
@ -25,6 +27,7 @@ type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64";
*/
export interface BinaryTarget {
readonly path: string;
readonly sha256: string;
readonly kind: "executable" | "database";
readonly format: "hopper" | "mach-o" | "elf" | "pe";
readonly architecture?: BinaryArchitecture;
@ -56,7 +59,13 @@ export const parseBinaryTarget = async (
targetKind === "database" ||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
)
return ok({ path, kind: "database", format: "hopper", loaderArgs: [] });
return ok({
path,
sha256: await sha256File(path),
kind: "database",
format: "hopper",
loaderArgs: [],
});
const handle = await open(path, "r");
let detected: Result<ExecutableMetadata, string>;
try {
@ -65,7 +74,12 @@ export const parseBinaryTarget = async (
await handle.close();
}
if (!detected.ok) return err(new BinaryTargetError(path, detected.error));
return ok({ path, kind: "executable", ...detected.value });
return ok({
path,
sha256: await sha256File(path),
kind: "executable",
...detected.value,
});
} catch (cause: unknown) {
return err(
new BinaryTargetError(candidate, "path is not readable", { cause }),
@ -73,6 +87,12 @@ export const parseBinaryTarget = async (
}
};
const sha256File = async (path: string): Promise<string> => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
};
const resolveAppBundle = async (
path: string,
): Promise<Result<string, BinaryTargetError>> => {

View File

@ -1,8 +1,16 @@
/** Base class for expected Hopper integration failures. */
export abstract class HopperError extends Error {
/** Base class for expected analysis, provider, and session failures. */
export abstract class AnalysisError extends Error {
abstract readonly _tag: string;
}
/** Base class for failures produced specifically by the Hopper provider. */
export abstract class HopperError extends AnalysisError {}
/** Provider-neutral invalid analysis input or output at an application boundary. */
export class AnalysisProtocolError extends AnalysisError {
readonly _tag = "HopperProtocolError";
}
/** Hopper did not respond within the configured operation deadline. */
export class HopperTimeoutError extends HopperError {
readonly _tag = "HopperTimeoutError";
@ -57,12 +65,12 @@ export class HopperStartError extends HopperError {
}
/** Runtime configuration could not be parsed safely. */
export class ConfigurationError extends HopperError {
export class ConfigurationError extends AnalysisError {
readonly _tag = "ConfigurationError";
}
/** No app or binary session exists for an analysis request. */
export class NoBinaryOpenError extends HopperError {
export class NoBinaryOpenError extends AnalysisError {
readonly _tag = "NoBinaryOpenError";
constructor() {
super(
@ -72,7 +80,7 @@ export class NoBinaryOpenError extends HopperError {
}
/** A supplied target path could not be safely opened as a supported app or binary. */
export class BinaryTargetError extends HopperError {
export class BinaryTargetError extends AnalysisError {
readonly _tag = "BinaryTargetError";
constructor(
readonly path: string,

62
src/domain/evidence.ts Normal file
View File

@ -0,0 +1,62 @@
import { z } from "zod";
import type { BinaryTarget } from "./binaryTarget.js";
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
const artifactEvidenceSchema = z.object({
path: z.string(),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
});
/** Strict JSON representation of one successful public analysis observation. */
export const evidenceSchema = z.object({
schema_version: z.literal(1),
artifact: artifactEvidenceSchema.nullable(),
provider: z.object({ id: z.literal("hopper"), version: z.null() }),
operation: z.string().min(1),
parameters: z.record(z.string(), jsonValueSchema),
result: jsonValueSchema,
confidence: z.literal("observed"),
limitations: z.array(z.string()),
});
export type Evidence = z.infer<typeof evidenceSchema>;
export interface EvidenceObservation {
readonly operation: string;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly result: JsonValue;
readonly limitations?: readonly string[];
}
/** Build deterministic evidence without claiming unavailable provider metadata. */
export const createEvidence = (
target: BinaryTarget | undefined,
observation: EvidenceObservation,
): Evidence =>
evidenceSchema.parse({
schema_version: 1,
artifact:
target === undefined
? null
: {
path: target.path,
sha256: target.sha256,
format: target.format,
architecture: target.architecture ?? null,
},
provider: { id: "hopper", version: null },
operation: observation.operation,
parameters: observation.parameters,
result: observation.result,
confidence: "observed",
limitations:
target === undefined
? [
"Artifact identity is unavailable for this fixed-target adapter.",
...(observation.limitations ?? []),
]
: [...(observation.limitations ?? [])],
});

View File

@ -1,6 +1,6 @@
import { z } from "zod";
import type { JsonValue } from "../hopper/protocol.js";
import type { JsonValue } from "./jsonValue.js";
import { HopperProtocolError } from "./errors.js";
import { err, ok, type Result } from "./result.js";
@ -15,6 +15,12 @@ export interface SegmentSummary {
readonly end: string;
}
export interface AddressedPage {
readonly items: readonly AddressedName[];
readonly nextOffset: number | null;
readonly hasMore: boolean;
}
const procedureMapSchema = z.record(z.string(), z.string());
const addressedNamesSchema = z.array(
z.object({ address: z.string(), name: z.string() }),
@ -25,11 +31,35 @@ const segmentSchema = z.object({
start: z.string().default(""),
end: z.string().default(""),
});
const addressedPageSchema = z.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
next_offset: z.number().int().min(0).nullable(),
has_more: z.boolean(),
});
/** Parse page entries and continuation metadata returned by list operations. */
export const parseAddressedPage = (
value: JsonValue,
): Result<AddressedPage, HopperProtocolError> => {
const parsed = addressedPageSchema.safeParse(value);
return parsed.success
? ok({
items: parsed.data.items.map(({ address, value: name }) => ({
address,
name,
})),
nextOffset: parsed.data.next_offset,
hasMore: parsed.data.has_more,
})
: invalid("addressed page", parsed.error);
};
/** Parse Hopper's direct or wrapped procedure map into stable entries. */
export const parseProcedures = (
value: JsonValue,
): Result<readonly AddressedName[], HopperProtocolError> => {
const page = parseAddressedPage(value);
if (page.ok) return ok(page.value.items);
const parsed = procedureMapSchema.safeParse(
unwrapProperty(value, "procedures"),
);
@ -40,7 +70,7 @@ export const parseProcedures = (
name,
})),
)
: invalid("procedure map", parsed.error);
: page;
};
/** Parse Hopper's direct or wrapped list of address/name records. */
@ -48,6 +78,15 @@ export const parseNames = (
value: JsonValue,
): Result<readonly AddressedName[], HopperProtocolError> => {
const unwrapped = unwrapProperty(value, "names");
const page = z
.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
})
.safeParse(unwrapped);
if (page.success)
return ok(
page.data.items.map(({ address, value: name }) => ({ address, name })),
);
const records = addressedNamesSchema.safeParse(unwrapped);
if (records.success) return ok(records.data);
const map = addressedNameMapSchema.safeParse(unwrapped);
@ -97,6 +136,12 @@ export const parseListCount = (
property: string,
): Result<number, HopperProtocolError> => {
const unwrapped = unwrapProperty(value, property);
const pageTotal = z
.object({ total: z.number().int().min(0) })
.safeParse(unwrapped);
if (pageTotal.success) return ok(pageTotal.data.total);
const page = z.object({ items: z.array(z.unknown()) }).safeParse(unwrapped);
if (page.success) return ok(page.data.items.length);
const list = z.array(z.unknown()).safeParse(unwrapped);
if (list.success) return ok(list.data.length);
const map = z.record(z.string(), z.unknown()).safeParse(unwrapped);

6
src/domain/jsonValue.ts Normal file
View File

@ -0,0 +1,6 @@
import { z } from "zod";
/** JSON-safe value shared by domain, application, and adapter boundaries. */
export const jsonValueSchema = z.json();
export type JsonValue = z.infer<typeof jsonValueSchema>;

View File

@ -1,4 +1,4 @@
import type { JsonValue } from "../hopper/protocol.js";
import type { JsonValue } from "./jsonValue.js";
import type { AddressedName } from "./hopperValues.js";
const MAX_SCANNED_SYMBOLS = 5_000;

View File

@ -0,0 +1,67 @@
import { fileURLToPath } from "node:url";
import type {
AnalysisClient,
AnalysisProvider,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import type { AppConfig } from "../config.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { Logger } from "../logger.js";
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
import { HopperClient } from "./HopperClient.js";
const IDENTITY: ProviderIdentity = {
id: "hopper",
name: "Hopper Disassembler",
version: null,
};
const CAPABILITIES = [
"direct-analysis",
"decompilation",
"disassembly",
"cross-references",
"containing-procedure-resolution",
"procedure-references",
"analysis-metadata-mutation",
] as const;
/** Concrete analysis provider backed by REA's private Hopper bridge. */
export class HopperProvider implements AnalysisProvider {
constructor(
private readonly config: AppConfig,
private readonly logger: Logger,
) {}
identity(): ProviderIdentity {
return IDENTITY;
}
capabilities(): readonly string[] {
return CAPABILITIES;
}
createClient(target: BinaryTarget): AnalysisClient {
const client = new HopperClient({
launcher: new HopperApplicationLauncher({
launcherPath: this.config.hopperLauncherPath,
targetPath: target.path,
targetKind: target.kind,
loaderArgs:
this.config.hopperLoaderArgs.length > 0
? this.config.hopperLoaderArgs
: target.loaderArgs,
bridgeScriptPath: fileURLToPath(
new URL("../../bridge/hopper_bridge.py", import.meta.url),
),
}),
logger: this.logger.child({ layer: "bridge" }),
});
return {
execute: (operation, parameters, options) =>
client.callTool(operation, parameters, options),
close: () => client.close(),
};
}
}

View File

@ -1,10 +1,10 @@
import { z } from "zod";
import { HopperProtocolError, HopperRemoteError } from "../domain/errors.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
export const jsonValueSchema = z.json();
export type JsonValue = z.infer<typeof jsonValueSchema>;
export { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
const responseSchema = z.union([
z.object({

View File

@ -1,7 +1,7 @@
import { McpServer } from "@modelcontextprotocol/server";
import type { HopperToolPort } from "../application/HopperToolPort.js";
import type { BinarySession } from "../application/BinarySession.js";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { registerEnhancedTools } from "./registerEnhancedTools.js";
import { registerOfficialTools } from "./registerOfficialTools.js";
@ -14,8 +14,8 @@ import { silentLogger, type Logger } from "../logger.js";
* fixed-target seam used by focused tests and embedders.
*/
export const createServer = (
hopper: HopperToolPort,
session?: BinarySession,
analysis: AnalysisOperationPort,
session?: BinarySessionPort,
logger: Logger = silentLogger,
): McpServer => {
const server = new McpServer(
@ -24,13 +24,15 @@ export const createServer = (
capabilities: { tools: {} },
instructions:
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.",
? "Reverse-engineering tools for an active analysis target. Start with binary_overview."
: "Reverse-engineering tools for configured analysis providers. Open a target with open_binary, then start with binary_overview.",
},
);
const toolLogger = logger.child({ layer: "server" });
registerOfficialTools(server, hopper, toolLogger);
registerEnhancedTools(server, hopper, toolLogger);
const activeTarget =
session === undefined ? undefined : () => session.activeTarget();
registerOfficialTools(server, analysis, toolLogger, activeTarget);
registerEnhancedTools(server, analysis, toolLogger, activeTarget);
if (session !== undefined) registerSessionTools(server, session, toolLogger);
return server;
};

View File

@ -1,7 +1,7 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { EnhancedTools } from "../application/EnhancedTools.js";
import type { HopperToolPort } from "../application/HopperToolPort.js";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
import {
ENHANCED_TOOL_CONTRACTS,
@ -10,16 +10,21 @@ import {
import { toCallToolResult } from "./toolResult.js";
import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence } from "../domain/evidence.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
/** Register composed workflows against the same port as direct bridge tools. */
export const registerEnhancedTools = (
server: McpServer,
hopper: HopperToolPort,
analysis: AnalysisOperationPort,
logger: Logger,
activeTarget?: () => BinaryTarget | undefined,
): void => {
const services = new EnhancedTools(hopper);
const services = new EnhancedTools(analysis);
for (const contract of ENHANCED_TOOL_CONTRACTS) {
registerEnhancedTool(server, services, contract, logger);
registerEnhancedTool(server, services, contract, { logger, activeTarget });
}
};
@ -27,17 +32,47 @@ const registerEnhancedTool = (
server: McpServer,
services: EnhancedTools,
contract: ToolContract,
logger: Logger,
registration: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
},
): void => {
const name = enhancedToolNameSchema.parse(contract.name);
server.registerTool(
name,
{ description: contract.description, inputSchema: contract.inputSchema },
async (input, context) =>
toCallToolResult(
await logToolExecution(logger, name, () =>
services.execute(name, input, context.mcpReq.signal),
),
),
{
description: contract.description,
inputSchema: contract.inputSchema,
outputSchema: contract.outputSchema,
annotations: contract.annotations,
},
async (input, context) => {
const parsedInput = jsonValueSchema.parse(
enhancedInputSchemas[name].parse(input),
);
if (
typeof parsedInput !== "object" ||
parsedInput === null ||
Array.isArray(parsedInput)
)
throw new Error("Validated enhanced input was not a JSON object");
const parameters: Record<string, JsonValue> = { ...parsedInput };
const result = await logToolExecution(registration.logger, name, () =>
services.execute(name, input, context.mcpReq.signal),
);
return toCallToolResult(
result.ok
? {
ok: true,
value: createEvidence(registration.activeTarget?.(), {
operation: name,
parameters,
result: result.value,
}),
}
: result,
contract,
);
},
);
};

View File

@ -1,46 +1,69 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { HopperToolPort } from "../application/HopperToolPort.js";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import {
OFFICIAL_TOOL_CONTRACTS,
type ToolContract,
} from "../contracts/toolContracts.js";
import { jsonValueSchema, type JsonValue } from "../hopper/protocol.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { toCallToolResult } from "./toolResult.js";
import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence } from "../domain/evidence.js";
/** Register direct bridge proxies, preserving MCP cancellation and typed errors. */
export const registerOfficialTools = (
server: McpServer,
hopper: HopperToolPort,
analysis: AnalysisOperationPort,
logger: Logger,
activeTarget?: () => BinaryTarget | undefined,
): void => {
for (const contract of OFFICIAL_TOOL_CONTRACTS) {
registerOfficialTool(server, hopper, contract, logger);
registerOfficialTool(server, analysis, contract, { logger, activeTarget });
}
};
const registerOfficialTool = (
server: McpServer,
hopper: HopperToolPort,
analysis: AnalysisOperationPort,
contract: ToolContract,
logger: Logger,
registration: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
},
): void => {
server.registerTool(
contract.name,
{
description: contract.description,
inputSchema: contract.inputSchema,
outputSchema: contract.outputSchema,
annotations: contract.annotations,
},
async (input, context) => {
const arguments_ = projectOfficialArguments(contract, input);
const result = await logToolExecution(logger, contract.name, () =>
hopper.callTool(contract.name, arguments_, {
signal: context.mcpReq.signal,
}),
const result = await logToolExecution(
registration.logger,
contract.name,
() =>
analysis.execute(contract.name, arguments_, {
signal: context.mcpReq.signal,
}),
);
return toCallToolResult(
result.ok
? {
ok: true,
value: createEvidence(registration.activeTarget?.(), {
operation: contract.name,
parameters: arguments_,
result: result.value,
}),
}
: result,
contract,
);
return toCallToolResult(result);
},
);
};

View File

@ -1,7 +1,7 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
import type { BinarySession } from "../application/BinarySession.js";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { toCallToolResult } from "./toolResult.js";
import type { Logger } from "../logger.js";
@ -10,7 +10,7 @@ import { logToolExecution } from "./toolLogging.js";
/** Register MCP-only target lifecycle operations on a long-lived session. */
export const registerSessionTools = (
server: McpServer,
session: BinarySession,
session: BinarySessionPort,
logger: Logger,
): void => {
const [openContract, closeContract, statusContract] = SESSION_TOOL_CONTRACTS;
@ -19,6 +19,8 @@ export const registerSessionTools = (
{
description: openContract.description,
inputSchema: openContract.inputSchema,
outputSchema: openContract.outputSchema,
annotations: openContract.annotations,
},
async (input, context) => {
const parsed = z.object({ path: z.string().min(1) }).parse(input);
@ -26,16 +28,21 @@ export const registerSessionTools = (
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(
{
ok: true,
value: {
path: opened.value.path,
format: opened.value.format,
kind: opened.value.kind,
loaderArgs: [...opened.value.loaderArgs],
sha256: opened.value.sha256,
architecture: opened.value.architecture ?? null,
},
},
})
: toCallToolResult(opened);
openContract,
)
: toCallToolResult(opened, openContract);
},
);
server.registerTool(
@ -43,12 +50,15 @@ export const registerSessionTools = (
{
description: closeContract.description,
inputSchema: closeContract.inputSchema,
outputSchema: closeContract.outputSchema,
annotations: closeContract.annotations,
},
async () =>
toCallToolResult(
await logToolExecution(logger, closeContract.name, () =>
session.close(),
),
closeContract,
),
);
server.registerTool(
@ -56,7 +66,10 @@ export const registerSessionTools = (
{
description: statusContract.description,
inputSchema: statusContract.inputSchema,
outputSchema: statusContract.outputSchema,
annotations: statusContract.annotations,
},
() => toCallToolResult({ ok: true, value: session.status() }),
() =>
toCallToolResult({ ok: true, value: session.status() }, statusContract),
);
};

View File

@ -1,8 +1,9 @@
import type { CallToolResult } from "@modelcontextprotocol/server";
import type { HopperError } from "../domain/errors.js";
import type { AnalysisError } from "../domain/errors.js";
import type { Result } from "../domain/result.js";
import type { JsonValue } from "../hopper/protocol.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ToolContract } from "../contracts/toolContracts.js";
/**
* Translate an application result into MCP text content.
@ -10,10 +11,11 @@ import type { JsonValue } from "../hopper/protocol.js";
* output, and other potentially sensitive details stay private.
*/
export const toCallToolResult = (
result: Result<JsonValue, HopperError>,
result: Result<JsonValue, AnalysisError>,
contract: ToolContract,
): CallToolResult =>
result.ok
? { content: [{ type: "text", text: formatValue(result.value) }] }
? successResult(result.value, contract)
: {
content: [
{
@ -24,6 +26,29 @@ export const toCallToolResult = (
isError: true,
};
const successResult = (
value: JsonValue,
contract: ToolContract,
): CallToolResult => {
const candidate = contract.kind === "session" ? { result: value } : value;
const parsed = contract.outputSchema.safeParse(candidate);
if (!parsed.success) {
return {
content: [
{
type: "text",
text: "HopperProtocolError: Analysis output does not match the tool contract",
},
],
isError: true,
};
}
return {
content: [{ type: "text", text: formatValue(value) }],
structuredContent: candidate,
};
};
const formatValue = (value: JsonValue): string => {
if (value === null) return "OK";
if (typeof value === "string") return value;

View File

@ -3,10 +3,9 @@ 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 { BinarySession } from "../src/application/BinarySession.js";
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
import type { AnalysisProvider } from "../src/application/AnalysisProvider.js";
import { HopperStartError } from "../src/domain/errors.js";
import { err, ok } from "../src/domain/result.js";
@ -18,9 +17,35 @@ afterEach(async () => {
});
describe("binary session", () => {
it("runs through a non-Hopper analysis provider", async () => {
const [first] = await targets();
const operations: string[] = [];
const provider: AnalysisProvider = {
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
capabilities: () => ["fixture-analysis"],
createClient: () => ({
execute: (operation) => {
operations.push(operation);
return Promise.resolve(ok(operation));
},
close: () => Promise.resolve(),
}),
};
const session = new BinarySession(provider);
expect((await session.open(first)).ok).toBe(true);
expect(await session.execute("fixture_operation", {})).toEqual({
ok: true,
value: "fixture_operation",
});
expect(provider.identity().id).toBe("fixture");
expect(provider.capabilities()).toEqual(["fixture-analysis"]);
expect(operations).toEqual(["health", "fixture_operation"]);
await session.close();
});
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.execute("binary_overview", {})).ok).toBe(false);
expect(await session.close()).toEqual({ ok: true, value: null });
});
@ -34,7 +59,12 @@ describe("binary session", () => {
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(session.status()).toMatchObject({
open: true,
sha256:
"7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed",
architecture: null,
});
expect(JSON.stringify(session.status())).toContain("first.hop");
expect(created).toBe(3);
});
@ -70,7 +100,7 @@ describe("binary session", () => {
return value;
});
await session.open(first);
const call = session.callTool("long", {});
const call = session.execute("long", {});
const switching = session.open(second);
await Promise.resolve();
expect(clients[0]?.closed).toBe(0);
@ -106,11 +136,7 @@ describe("binary session", () => {
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 },
);
const call = session.execute("overview", {}, { signal: controller.signal });
controller.abort();
const result = await call;
expect(result.ok).toBe(false);
@ -143,7 +169,7 @@ describe("binary session", () => {
let liveClients = 0;
let overlapped = false;
const session = new BinarySession(() => ({
callTool: () => {
execute: () => {
if (liveClients > 0) overlapped = true;
liveClients += 1;
return Promise.resolve(ok(null));
@ -160,20 +186,19 @@ describe("binary session", () => {
});
});
const client = (fail = false): BinaryClient => ({
callTool: () =>
Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
const client = (fail = false): AnalysisClient => ({
execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
close: () => Promise.resolve(),
});
class TestClient implements BinaryClient {
class TestClient implements AnalysisClient {
closed = 0;
constructor(
readonly pendingHealth?: Promise<ReturnType<typeof ok<null>>>,
readonly failHealth = false,
readonly pendingCall?: Promise<ReturnType<typeof ok<null>>>,
) {}
callTool(name: string) {
execute(name: string) {
if (name === "health")
return this.failHealth
? Promise.resolve(err(new HopperStartError()))

View File

@ -77,7 +77,10 @@ describe("binary target I/O", () => {
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
await writeFile(join(directory, "sample.hop"), "database");
await writeFile(join(directory, "text"), "hello");
expect((await parseBinaryTarget("sample.hop", directory)).ok).toBe(true);
const database = await parseBinaryTarget("sample.hop", directory);
expect(database.ok && database.value.sha256).toBe(
"3549b0028b75d981cdda2e573e9cb49dedc200185876df299f912b79f69dabd8",
);
expect((await parseBinaryTarget("text", directory)).ok).toBe(false);
expect((await parseBinaryTarget("missing", directory)).ok).toBe(false);
});

View File

@ -0,0 +1,32 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
const bridgeSource = await readFile(
new URL("../bridge/hopper_bridge.py", import.meta.url),
"utf8",
);
describe("Hopper bridge truthfulness", () => {
it("marks unavailable CFG successors instead of returning deceptive empty arrays", () => {
expect(bridgeSource).toContain('"successors": _unavailable(');
expect(bridgeSource).not.toContain('"successors": []');
});
it("does not fabricate non-writable and non-executable segment permissions", () => {
expect(bridgeSource).toContain('"writable": None');
expect(bridgeSource).toContain('"executable": None');
expect(bridgeSource).toContain('"permissions": _unavailable(');
expect(bridgeSource).not.toContain('"writable": False');
expect(bridgeSource).not.toContain('"executable": False');
});
it("uses Hopper containment and raw-reference APIs without inferring kinds", () => {
expect(bridgeSource).toContain("segment.getProcedureAtAddress(address)");
expect(bridgeSource).toContain("segment.getReferencesFromAddress(address)");
expect(bridgeSource).toContain("segment.getReferencesOfAddress(address)");
expect(bridgeSource).toContain(
"Hopper's public Python API does not classify reference kinds",
);
});
});

View File

@ -0,0 +1,16 @@
# Source-built conformance fixtures
These sources are deliberately small semantic oracles for REA providers. Run
`npm run verify:fixtures` on macOS to compile them into the ignored
`build/conformance/` directory, emit a hash-and-toolchain manifest, and verify
their Mach-O symbols and strings with native command-line tools.
Only source files are versioned. Generated binaries, generated large-fixture
source, debug information, and manifests must remain under `build/`.
The C fixture fixes a known call chain and strings. The version pair fixes an
added symbol, changed string, and changed call relationship. The generated
large fixture contains 1,205 uniquely named functions and strings so provider
pagination can be checked at boundaries of 500, 1,000, and 1,205. Objective-C,
Swift, and N-API-like fixtures provide metadata-oriented inputs without package
or network dependencies.

View File

@ -0,0 +1,19 @@
#include <stdio.h>
volatile int rea_c_global = 7;
__attribute__((noinline, used)) int rea_leaf(int value) {
puts("REA_C_LEAF");
return value + rea_c_global;
}
__attribute__((noinline, used)) int rea_branch(int value) {
return value > 3 ? rea_leaf(value) : rea_leaf(-value);
}
__attribute__((noinline, used)) int rea_entry(void) {
puts("REA_C_ENTRY");
return rea_branch(5);
}
int main(void) { return rea_entry() == 12 ? 0 : 1; }

View File

@ -0,0 +1,17 @@
typedef void *napi_env;
typedef void *napi_value;
typedef napi_value (*napi_callback)(napi_env, void *);
extern int napi_create_function(napi_env, const char *, unsigned long,
napi_callback, void *, napi_value *);
extern int napi_set_named_property(napi_env, napi_value, const char *, napi_value);
static napi_value rea_probe(napi_env env, void *info) { return (napi_value)info; }
__attribute__((visibility("default"))) napi_value napi_register_module_v1(
napi_env env, napi_value exports) {
const char *names[] = {"reaProbeOne", "reaProbeTwo", "reaProbeThree"};
for (int i = 0; i < 3; i++) {
napi_value function = 0;
napi_create_function(env, names[i], 0, rea_probe, 0, &function);
napi_set_named_property(env, exports, names[i], function);
}
return exports;
}

View File

@ -0,0 +1,21 @@
#import <Foundation/Foundation.h>
@protocol REAWidgetDelegate <NSObject>
- (void)widgetDidFinish;
@end
@interface REAWidget : NSObject
@property(nonatomic, weak) id<REAWidgetDelegate> delegate;
- (BOOL)performAction:(NSString *)action error:(NSError **)error;
@end
@implementation REAWidget
- (BOOL)performAction:(NSString *)action error:(NSError **)error {
NSLog(@"REA_OBJC_ACTION:%@", action);
return action.length > 0;
}
@end
int main(void) {
@autoreleasepool {
Protocol *protocol = @protocol(REAWidgetDelegate);
NSLog(@"%@", NSStringFromProtocol(protocol));
return [[[REAWidget alloc] init] performAction:@"run" error:0] ? 0 : 1;
}
}

View File

@ -0,0 +1,11 @@
import Darwin
protocol REAProtocol { func execute() -> Int }
struct REARecord { let value: Int }
enum REAState { case ready, finished }
final class REAService: REAProtocol {
@inline(never) func execute() -> Int { print("REA_SWIFT_EXECUTE"); return 42 }
}
extension REARecord { @inline(never) func doubled() -> Int { value * 2 } }
let service = REAService()
exit(service.execute() == 42 ? 0 : 1)

View File

@ -0,0 +1,9 @@
#include <stdio.h>
__attribute__((noinline, used)) int rea_version_leaf(void) {
puts("REA_VERSION_ONE");
return 1;
}
__attribute__((noinline, used)) int rea_version_entry(void) {
return rea_version_leaf();
}
int main(void) { return rea_version_entry() == 1 ? 0 : 1; }

View File

@ -0,0 +1,10 @@
#include <stdio.h>
__attribute__((noinline, used)) int rea_version_leaf(void) {
puts("REA_VERSION_TWO");
return 2;
}
__attribute__((noinline, used)) int rea_added(void) { return 40; }
__attribute__((noinline, used)) int rea_version_entry(void) {
return rea_version_leaf() + rea_added();
}
int main(void) { return rea_version_entry() == 42 ? 0 : 1; }

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
generateLargeFixture,
LARGE_FIXTURE_COUNT,
sha256,
sourceDigest,
} from "../scripts/lib/conformance-fixtures.mjs";
describe("source-built conformance fixtures", () => {
it("generates a deterministic large pagination fixture", () => {
const first = generateLargeFixture();
const second = generateLargeFixture();
expect(first).toBe(second);
expect(first.match(/int rea_page_/gu)).toHaveLength(LARGE_FIXTURE_COUNT);
expect(first).toContain("rea_page_0000");
expect(first).toContain("rea_page_1204");
expect(() => generateLargeFixture(0)).toThrow(/positive integer/u);
});
it("hashes source manifests independently of input ordering", () => {
const left = [
{ path: "b.c", content: "b" },
{ path: "a.c", content: "a" },
];
const right = [...left].reverse();
expect(sourceDigest(left)).toBe(sourceDigest(right));
expect(sourceDigest(left)).toMatch(/^[0-9a-f]{64}$/u);
expect(sha256("fixture")).not.toBe(sha256("changed"));
});
});

View File

@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { z } from "zod";
@ -7,28 +6,34 @@ import {
OFFICIAL_TOOL_CONTRACTS,
SESSION_TOOL_CONTRACTS,
} from "../src/contracts/toolContracts.js";
import {
enhancedOutputSchemas,
officialOutputSchemas,
sessionOutputSchemas,
} from "../src/contracts/toolOutputSchemas.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, {
describe("tool contract surface", () => {
it("advertises complete typed schemas and annotations for all analysis tools", () => {
const contracts = [...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS];
expect(contracts).toHaveLength(43);
for (const contract of contracts) {
const 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);
});
const outputSchema = z.toJSONSchema(contract.outputSchema, {
target: "draft-07",
unrepresentable: "any",
});
expect(inputSchema.type).toBe("object");
expect(outputSchema.type).toBe("object");
expect(contract.annotations).toMatchObject({
idempotentHint: true,
openWorldHint: false,
});
expect(typeof contract.annotations.readOnlyHint).toBe("boolean");
expect(typeof contract.annotations.destructiveHint).toBe("boolean");
}
});
it("keeps exactly three additive session contracts", () => {
@ -40,4 +45,30 @@ describe("original tool contract snapshot", () => {
{ name: "binary_session", kind: "session" },
]);
});
it("publishes a dedicated output schema and agent guidance for every tool", () => {
expect(Object.keys(officialOutputSchemas).sort()).toEqual(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(Object.keys(enhancedOutputSchemas).sort()).toEqual(
ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(Object.keys(sessionOutputSchemas).sort()).toEqual(
SESSION_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
for (const contract of [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
]) {
const schema = z.toJSONSchema(contract.outputSchema, {
target: "draft-07",
unrepresentable: "any",
});
expect(JSON.stringify(schema)).not.toContain('"result":{}');
expect(contract.description.length).toBeGreaterThan(100);
expect(contract.description).toMatch(/[.;]/u);
}
});
});

View File

@ -4,17 +4,17 @@ import { describe, expect, it } from "vitest";
import { ok } from "../src/domain/result.js";
import { createServer } from "../src/server/createServer.js";
const hopper = {
callTool: () => Promise.resolve(ok(null)),
const analysis = {
execute: () => Promise.resolve(ok(null)),
};
describe("beta.3 server composition", () => {
it("constructs independent MCP server instances", () => {
expect(createServer(hopper)).not.toBe(createServer(hopper));
expect(createServer(analysis)).not.toBe(createServer(analysis));
});
it("does not advertise unavailable session tools", async () => {
const server = createServer(hopper);
const server = createServer(analysis);
const client = new Client({ name: "composition-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();

View File

@ -2,10 +2,10 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import type { HopperToolPort } from "../src/application/HopperToolPort.js";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { ENHANCED_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
import { ok } from "../src/domain/result.js";
import { jsonValueSchema, type JsonValue } from "../src/hopper/protocol.js";
import { jsonValueSchema, type JsonValue } from "../src/domain/jsonValue.js";
import { createServer } from "../src/server/createServer.js";
const PROCEDURES = {
@ -17,18 +17,29 @@ const PROCEDURES = {
"0x6": "prefix_TtOther",
};
const fixturePort = (): HopperToolPort => ({
callTool: (name, arguments_) => {
const page = (values: Readonly<Record<string, string>>) => ({
items: Object.entries(values).map(([address, value]) => ({ address, value })),
offset: 0,
limit: 100,
total: Object.keys(values).length,
next_offset: null,
has_more: false,
});
const fixturePort = (): AnalysisOperationPort => ({
execute: (name, arguments_) => {
switch (name) {
case "list_procedures":
return Promise.resolve(ok(PROCEDURES));
return Promise.resolve(ok(page(PROCEDURES)));
case "list_names":
return Promise.resolve(
ok([
{ address: "0x10", name: "_OBJC_CLASS_$_Fixture" },
{ address: "0x11", name: "_OBJC_CLASS_$_Fixture" },
{ address: "0x12", name: "_OBJC_PROTOCOL_$_FixtureDelegate" },
]),
ok(
page({
"0x10": "_OBJC_CLASS_$_Fixture",
"0x11": "_OBJC_CLASS_$_Fixture",
"0x12": "_OBJC_PROTOCOL_$_FixtureDelegate",
}),
),
);
case "procedure_pseudo_code": {
const procedure = arguments_.procedure;
@ -56,6 +67,18 @@ const fixturePort = (): HopperToolPort => ({
);
case "xrefs":
return Promise.resolve(ok(["0x20", "0x21"]));
case "resolve_containing_procedure":
return Promise.resolve(
ok({
query_address:
typeof arguments_.address === "string"
? arguments_.address
: "0x0",
found: false,
procedure: null,
reason: "not_in_procedure",
}),
);
case "list_segments":
return Promise.resolve(
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
@ -63,13 +86,53 @@ const fixturePort = (): HopperToolPort => ({
case "list_documents":
return Promise.resolve(ok(["fixture"]));
case "list_strings":
return Promise.resolve(ok([{ address: "0x30", value: "hello" }]));
return Promise.resolve(ok(page({ "0x30": "hello" })));
case "analyze_function":
return Promise.resolve(
ok({
procedure: {
address: "0x1",
name: "entry",
signature: null,
locals: [],
},
pseudocode: {
text: "return 0;",
total_chars: 9,
returned_chars: 9,
truncated: false,
next_offset: null,
},
assembly: {
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
},
comments: emptyBounded(),
callers: emptyBounded(),
callees: emptyBounded(),
incoming_references: emptyBounded(),
referenced_strings: { available: false, reason: "unsupported" },
referenced_names: { available: false, reason: "unsupported" },
basic_blocks: emptyBounded(),
}),
);
default:
return Promise.resolve(ok(null));
}
},
});
const emptyBounded = () => ({
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
});
const resources: Array<{ close(): Promise<void> }> = [];
afterEach(async () => {
@ -78,8 +141,8 @@ afterEach(async () => {
);
});
const connect = async (hopper: HopperToolPort = fixturePort()) => {
const server = createServer(hopper);
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
const server = createServer(analysis);
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
@ -90,6 +153,19 @@ const connect = async (hopper: HopperToolPort = fixturePort()) => {
};
const jsonResult = (result: CallToolResult): JsonValue => {
if (result.structuredContent === undefined)
throw new Error("Tool result omitted structured content");
const structured = jsonValueSchema.safeParse(result.structuredContent);
if (!structured.success)
throw new Error("Tool structured result was not JSON");
if (
typeof structured.data === "object" &&
structured.data !== null &&
!Array.isArray(structured.data) &&
"result" in structured.data
) {
return structured.data.result ?? null;
}
const text = result.content.find((item) => item.type === "text");
if (text?.type !== "text")
throw new Error("Tool result omitted text content");
@ -100,10 +176,10 @@ const jsonResult = (result: CallToolResult): JsonValue => {
};
describe("enhanced MCP tools", () => {
it("lists the complete 31 plus 8 surface", async () => {
it("lists the complete 33 plus 10 surface", async () => {
const client = await connect();
const listed = await client.listTools();
expect(listed.tools).toHaveLength(39);
expect(listed.tools).toHaveLength(43);
expect(
listed.tools
.map(({ name }) => name)
@ -114,7 +190,7 @@ describe("enhanced MCP tools", () => {
).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort());
});
it("executes all eight tools through production registration", async () => {
it("executes all ten tools through production registration", async () => {
const client = await connect();
const calls = [
["swift_classes", { pattern: "Fixture" }],
@ -125,6 +201,8 @@ describe("enhanced MCP tools", () => {
["analyze_swift_types", {}],
["find_xrefs_to_name", { name: "entry" }],
["binary_overview", {}],
["analyze_function", { procedure: "0x1" }],
["trace_feature", { query: "hello", max_operations: 10 }],
] as const;
const results = [];
for (const [name, arguments_] of calls) {
@ -152,13 +230,26 @@ describe("enhanced MCP tools", () => {
procedure_count: 6,
string_count: 1,
});
expect(results[8]).toMatchObject({
procedure: { address: "0x1", name: "entry" },
pseudocode: { text: "return 0;" },
});
expect(results[9]).toMatchObject({
query: "hello",
search_mode: "literal",
truncated: false,
references: [
{ target_address: "0x30", source_address: "0x20" },
{ target_address: "0x30", source_address: "0x21" },
],
});
});
it("bounds batch concurrency at the parsed maximum of 20", async () => {
let active = 0;
let maximum = 0;
const hopper: HopperToolPort = {
callTool: async (name) => {
const analysis: AnalysisOperationPort = {
execute: async (name) => {
if (name !== "procedure_pseudo_code") return ok(null);
active += 1;
maximum = Math.max(maximum, active);
@ -167,7 +258,7 @@ describe("enhanced MCP tools", () => {
return ok("pseudo");
},
};
const client = await connect(hopper);
const client = await connect(analysis);
const result = await client.callTool({
name: "batch_decompile",
arguments: {
@ -181,9 +272,198 @@ describe("enhanced MCP tools", () => {
expect(maximum).toBe(20);
});
it("follows procedure pagination for whole-binary workflows", async () => {
const offsets: number[] = [];
const client = await connect({
execute: (_name, arguments_) => {
const offset = arguments_.offset;
offsets.push(typeof offset === "number" ? offset : 0);
return Promise.resolve(
ok({
items: [
{
address: offset === 500 ? "0x2" : "0x1",
value: offset === 500 ? "_TtC4Last" : "_TtC5First",
},
],
offset: typeof offset === "number" ? offset : 0,
limit: 500,
total: 2,
next_offset: offset === 500 ? null : 500,
has_more: offset !== 500,
}),
);
},
});
const result = jsonResult(
await client.callTool({ name: "swift_classes", arguments: {} }),
);
expect(offsets).toEqual([0, 500]);
expect(result).toMatchObject({ count: 2 });
});
it("follows name pagination for exhaustive Objective-C discovery", async () => {
const offsets: number[] = [];
const client = await connect({
execute: (name, arguments_) => {
expect(name).toBe("list_names");
const offset =
typeof arguments_.offset === "number" ? arguments_.offset : 0;
offsets.push(offset);
return Promise.resolve(
ok({
items: [
{
address: offset === 500 ? "0x2" : "0x1",
value:
offset === 500 ? "_OBJC_CLASS_$_Last" : "_OBJC_CLASS_$_First",
},
],
offset,
limit: 500,
total: 2,
next_offset: offset === 500 ? null : 500,
has_more: offset !== 500,
}),
);
},
});
const result = jsonResult(
await client.callTool({ name: "get_objc_classes", arguments: {} }),
);
expect(offsets).toEqual([0, 500]);
expect(result).toMatchObject({ count: 2 });
});
it("honors overview detail and limit while reporting exhaustive totals", async () => {
const procedureOffsets: number[] = [];
const client = await connect({
execute: (name, arguments_) => {
switch (name) {
case "list_segments":
return Promise.resolve(
ok([
{ name: "__TEXT", start: "0x1000", end: "0x1800" },
{ name: "__DATA", start: "0x1800", end: "0x2000" },
]),
);
case "list_documents":
return Promise.resolve(ok(["fixture"]));
case "list_strings":
return Promise.resolve(
ok({
items: [{ address: "0x30", value: "first page only" }],
offset: 0,
limit: 100,
total: 700,
next_offset: 100,
has_more: true,
}),
);
case "list_procedures": {
const offset =
typeof arguments_.offset === "number" ? arguments_.offset : 0;
procedureOffsets.push(offset);
return Promise.resolve(
ok({
items: [
{
address: offset === 500 ? "0x2" : "0x1",
value: offset === 500 ? "last" : "first",
},
],
offset,
limit: 500,
total: 2,
next_offset: offset === 500 ? null : 500,
has_more: offset !== 500,
}),
);
}
default:
return Promise.resolve(ok(null));
}
},
});
const result = jsonResult(
await client.callTool({
name: "binary_overview",
arguments: { detail: "detailed", limit: 1 },
}),
);
expect(procedureOffsets).toEqual([0, 500]);
expect(result).toEqual({
document: "fixture",
detail: "detailed",
segments: [
{ name: "__TEXT", start: "0x1000", end: "0x1800", length: 2048 },
],
segment_count: 2,
procedure_count: 2,
string_count: 700,
});
});
it("rejects non-advancing pagination metadata", async () => {
const client = await connect({
execute: () =>
Promise.resolve(
ok({
items: [{ address: "0x1", value: "_TtC5First" }],
offset: 0,
limit: 500,
total: 2,
next_offset: 0,
has_more: true,
}),
),
});
const result = await client.callTool({
name: "swift_classes",
arguments: {},
});
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
expect(text?.type === "text" ? text.text : "").toContain(
"non-advancing pagination offset",
);
});
it("stops trace_feature at its explicit operation budget", async () => {
let operations = 0;
const client = await connect({
execute: () => {
operations += 1;
return Promise.resolve(
ok({
items: [{ address: "0x1", value: "needle" }],
offset: 0,
limit: 500,
total: 2,
next_offset: 1,
has_more: true,
}),
);
},
});
const result = jsonResult(
await client.callTool({
name: "trace_feature",
arguments: { query: "needle", max_operations: 1 },
}),
);
expect(operations).toBe(1);
expect(result).toMatchObject({
operations_used: 1,
operation_budget: 1,
truncated: true,
});
expect(JSON.stringify(result)).toContain("operation budget");
});
it("returns a typed tool error for malformed Hopper boundary values", async () => {
const client = await connect({
callTool: () => Promise.resolve(ok(["not", "a", "procedure", "map"])),
execute: () => Promise.resolve(ok(["not", "a", "procedure", "map"])),
});
const result = await client.callTool({
name: "swift_classes",

56
tests/evidence.test.ts Normal file
View File

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
import { createEvidence, evidenceSchema } from "../src/domain/evidence.js";
import { jsonValueSchema } from "../src/hopper/protocol.js";
const TARGET: BinaryTarget = {
path: "/tmp/fixture",
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["arm64"],
loaderArgs: ["-l", "Mach-O", "--aarch64"],
};
describe("analysis evidence", () => {
it("preserves strict JSON inputs and results without timestamps", () => {
const evidence = createEvidence(TARGET, {
operation: "procedure_info",
parameters: { procedure: "0x1000", document: null },
result: { name: "main" },
});
expect(evidenceSchema.parse(evidence)).toEqual(evidence);
expect(jsonValueSchema.parse(evidence)).toEqual(evidence);
expect(evidence).toMatchObject({
schema_version: 1,
artifact: {
path: "/tmp/fixture",
sha256: "a".repeat(64),
architecture: "arm64",
},
provider: { id: "hopper", version: null },
operation: "procedure_info",
confidence: "observed",
limitations: [],
});
expect(evidence).not.toHaveProperty("timestamp");
expect(evidence).not.toHaveProperty("evidence_id");
});
it("marks missing fixed-target identity as unavailable", () => {
expect(
createEvidence(undefined, {
operation: "health",
parameters: {},
result: null,
}),
).toMatchObject({
artifact: null,
limitations: [
"Artifact identity is unavailable for this fixed-target adapter.",
],
});
});
});

View File

@ -44,6 +44,34 @@ const server = createServer((socket) => {
id: request.id,
error: { code: -32001, message: "safe fake failure" },
});
} else if (request.method === "current_document") {
send({ id: request.id, result: "fixture" });
} else if (request.method === "resolve_containing_procedure") {
send({
id: request.id,
result: {
query_address: request.params.address,
found: true,
procedure: { address: "0x1000", name: "fixture" },
},
});
} else if (request.method === "procedure_references") {
send({
id: request.id,
result: {
procedure: { address: "0x1000", name: "fixture" },
direction: request.params.direction ?? "outgoing",
references: {
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
},
instructions_scanned: 1,
instruction_scan_truncated: false,
},
});
} else {
const delay = request.params?.delay ?? 0;
setTimeout(

View File

@ -102,7 +102,7 @@ describe("HopperClient", () => {
const results = await Promise.all(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => client.callTool(name, {})),
);
expect(results).toHaveLength(31);
expect(results).toHaveLength(33);
expect(results.every((result) => result.ok)).toBe(true);
});

View File

@ -2,11 +2,11 @@ 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 type { HopperToolPort } from "../../src/application/HopperToolPort.js";
import { BinarySession } from "../../src/application/BinarySession.js";
import type {
AnalysisClient,
AnalysisOperationPort,
} from "../../src/application/AnalysisProvider.js";
import { HopperRemoteError } from "../../src/domain/errors.js";
import { err, ok } from "../../src/domain/result.js";
import { createServer } from "../../src/server/createServer.js";
@ -19,8 +19,8 @@ afterEach(async () => {
);
});
const connect = async (hopper: HopperToolPort) => {
const server = createServer(hopper);
const connect = async (analysis: AnalysisOperationPort) => {
const server = createServer(analysis);
const client = new Client({
name: "integration-test",
version: "1.0.0",
@ -39,14 +39,33 @@ const text = (result: CallToolResult): string => {
return content.text;
};
const structured = (result: CallToolResult): Record<string, unknown> => {
if (
typeof result.structuredContent !== "object" ||
result.structuredContent === null
)
throw new Error("missing structured result");
return Object.fromEntries(Object.entries(result.structuredContent));
};
describe("full MCP integration with multi-tool sequences", () => {
it("executes a realistic workflow: list methods, decompile selected, get xrefs", async () => {
const client = await connect({
callTool: (name, args) => {
execute: (name, args) => {
switch (name) {
case "list_procedures":
return Promise.resolve(
ok({ "0x1000": "main", "0x2000": "helper" }),
ok({
items: [
{ address: "0x1000", value: "main" },
{ address: "0x2000", value: "helper" },
],
offset: 0,
limit: 100,
total: 2,
next_offset: null,
has_more: false,
}),
);
case "procedure_pseudo_code":
return Promise.resolve(
@ -65,32 +84,50 @@ describe("full MCP integration with multi-tool sequences", () => {
arguments: {},
});
expect(listResult.isError).not.toBe(true);
const procedures = JSON.parse(text(listResult));
expect(procedures).toEqual({ "0x1000": "main", "0x2000": "helper" });
expect(structured(listResult)).toMatchObject({
artifact: null,
operation: "list_procedures",
provider: { id: "hopper", version: null },
result: {
items: [
{ address: "0x1000", value: "main" },
{ address: "0x2000", value: "helper" },
],
total: 2,
has_more: false,
},
});
const decompileResult = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "0x1000" },
});
expect(text(decompileResult)).toBe("pseudo for 0x1000");
expect(structured(decompileResult)).toMatchObject({
operation: "procedure_pseudo_code",
parameters: { document: null, procedure: "0x1000" },
result: "pseudo for 0x1000",
});
const xrefResult = await client.callTool({
name: "xrefs",
arguments: {},
});
expect(JSON.parse(text(xrefResult))).toEqual(["0x1000"]);
expect(structured(xrefResult)).toMatchObject({
operation: "xrefs",
result: ["0x1000"],
});
});
it("preserves the complete 42-tool inventory with a session", async () => {
it("preserves the complete 46-tool inventory with a session", async () => {
const session = new BinarySession(
(_path) =>
({
callTool: () => Promise.resolve(ok(null)),
execute: () => Promise.resolve(ok(null)),
close: () => Promise.resolve(),
}) satisfies BinaryClient,
}) satisfies AnalysisClient,
);
const server = createServer(
{ callTool: () => Promise.resolve(ok(null)) },
{ execute: () => Promise.resolve(ok(null)) },
session,
);
const client = new Client({
@ -104,7 +141,7 @@ describe("full MCP integration with multi-tool sequences", () => {
await client.connect(clientTransport);
const listed = await client.listTools();
expect(listed.tools).toHaveLength(42);
expect(listed.tools).toHaveLength(46);
const names = listed.tools.map((t) => t.name);
expect(names).toContain("open_binary");
expect(names).toContain("close_binary");
@ -113,12 +150,12 @@ describe("full MCP integration with multi-tool sequences", () => {
expect(names).toContain("batch_decompile");
});
it("preserves the 39-tool inventory without a session", async () => {
it("preserves the 43-tool inventory without a session", async () => {
const client = await connect({
callTool: () => Promise.resolve(ok(null)),
execute: () => Promise.resolve(ok(null)),
});
const listed = await client.listTools();
expect(listed.tools).toHaveLength(39);
expect(listed.tools).toHaveLength(43);
const names = listed.tools.map((t) => t.name);
expect(names).toContain("binary_overview");
expect(names).toContain("batch_decompile");
@ -127,7 +164,7 @@ describe("full MCP integration with multi-tool sequences", () => {
it("propagates remote Hopper errors with structured content", async () => {
const client = await connect({
callTool: () =>
execute: () =>
Promise.resolve(err(new HopperRemoteError(-32000, "bridge timeout"))),
});
@ -143,9 +180,22 @@ describe("full MCP integration with multi-tool sequences", () => {
it("handles concurrent tool calls without corruption", async () => {
const invocations: string[] = [];
const client = await connect({
callTool: (name) => {
execute: (name) => {
invocations.push(name);
return Promise.resolve(ok({ handled: name }));
return Promise.resolve(
ok(
["list_procedures", "list_strings"].includes(name)
? {
items: [],
offset: 0,
limit: 100,
total: 0,
next_offset: null,
has_more: false,
}
: [],
),
);
},
});

View File

@ -1,11 +1,11 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, describe, expect, it } from "vitest";
import type { HopperToolPort } from "../src/application/HopperToolPort.js";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
import { HopperRemoteError } from "../src/domain/errors.js";
import { err, ok } from "../src/domain/result.js";
import type { JsonValue } from "../src/hopper/protocol.js";
import type { JsonValue } from "../src/domain/jsonValue.js";
import { createServer } from "../src/server/createServer.js";
const VALID_INPUTS: Readonly<
@ -31,7 +31,9 @@ const VALID_INPUTS: Readonly<
procedure_callees: { procedure: "main" },
procedure_callers: { procedure: "main" },
procedure_info: { procedure: "main" },
procedure_references: { procedure: "main" },
procedure_pseudo_code: { procedure: "main" },
resolve_containing_procedure: { address: "0x1000" },
search_procedures: { pattern: "main" },
search_strings: { pattern: "hello" },
set_address_name: { address: "0x1000", name: "entry" },
@ -57,8 +59,8 @@ afterEach(async () => {
);
});
const connect = async (hopper: HopperToolPort) => {
const server = createServer(hopper);
const connect = async (analysis: AnalysisOperationPort) => {
const server = createServer(analysis);
const client = new Client({ name: "contract-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
@ -68,10 +70,71 @@ const connect = async (hopper: HopperToolPort) => {
return client;
};
const page: JsonValue = {
items: [],
offset: 0,
limit: 100,
total: 0,
next_offset: null,
has_more: false,
};
const outputFor = (name: string): JsonValue => {
if (["list_procedures", "list_names", "list_strings"].includes(name))
return page;
if (["address_name", "comment", "inline_comment"].includes(name)) return null;
if (["procedure_callees", "procedure_callers", "xrefs"].includes(name))
return [];
if (["list_bookmarks", "list_documents", "list_segments"].includes(name))
return [];
if (
[
"set_address_name",
"set_bookmark",
"set_comment",
"set_inline_comment",
"unset_bookmark",
].includes(name)
)
return true;
if (name === "set_addresses_names") return { "0x1000": true };
if (["search_procedures", "search_strings"].includes(name)) return {};
if (name === "procedure_info")
return {
name: "main",
entrypoint: "0x1000",
basicblock_count: 1,
length: 4,
signature: null,
locals: [],
};
if (name === "resolve_containing_procedure")
return {
query_address: "0x1000",
found: true,
procedure: { address: "0x1000", name: "main" },
};
if (name === "procedure_references")
return {
procedure: { address: "0x1000", name: "main" },
direction: "outgoing",
references: {
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
},
instructions_scanned: 1,
instruction_scan_truncated: false,
};
return name.includes("address") ? "0x1000" : "fixture";
};
describe("official Hopper proxy tools", () => {
it("lists exactly the 31 official contracts", async () => {
it("lists every official contract", async () => {
const client = await connect({
callTool: () => Promise.resolve(ok(null)),
execute: () => Promise.resolve(ok(null)),
});
const listed = await client.listTools();
const officialNames = new Set(
@ -88,9 +151,9 @@ describe("official Hopper proxy tools", () => {
it("executes every handler and projects omitted Python optionals to null", async () => {
const invocations: Invocation[] = [];
const client = await connect({
callTool: (name, arguments_) => {
execute: (name, arguments_) => {
invocations.push({ name, arguments_ });
return Promise.resolve(ok({ name, arguments: arguments_ }));
return Promise.resolve(ok(outputFor(name)));
},
});
@ -122,7 +185,7 @@ describe("official Hopper proxy tools", () => {
it("returns stable safe MCP error content", async () => {
const client = await connect({
callTool: () => Promise.resolve(err(new HopperRemoteError(-1, "denied"))),
execute: () => Promise.resolve(err(new HopperRemoteError(-1, "denied"))),
});
const result = await client.callTool({
name: "list_documents",

View File

@ -0,0 +1,40 @@
import { readdir, readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const sourceRoot = new URL("../src/", import.meta.url);
describe("provider-neutral architecture", () => {
it("keeps Hopper imports behind its adapter and composition root", async () => {
const forbiddenRoots = ["domain", "contracts", "server", "application"];
const violations: string[] = [];
for (const root of forbiddenRoots) {
for (const file of await typescriptFiles(
fileURLToPath(new URL(`${root}/`, sourceRoot)),
)) {
if (root === "application" && file.endsWith("/runtime.ts")) continue;
const source = await readFile(file, "utf8");
if (/from\s+["'][^"']*\/hopper\//u.test(source)) {
violations.push(relative(fileURLToPath(sourceRoot), file));
}
}
}
expect(violations).toEqual([]);
});
});
const typescriptFiles = async (
directory: string,
): Promise<readonly string[]> => {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return typescriptFiles(path);
return entry.isFile() && entry.name.endsWith(".ts") ? [path] : [];
}),
);
return nested.flat();
};

View File

@ -22,9 +22,9 @@ describe("localized README product facts", () => {
expect(content).toContain('"args": ["-y", "@morluto/rea", "mcp"]');
expect(content).toContain("Node.js 22");
expect(content).toContain("macOS 12");
expect(content).toMatch(/\b42\b/u);
expect(content).toMatch(/\b31\b/u);
expect(content).toMatch(/\b8\b/u);
expect(content).toMatch(/\b46\b/u);
expect(content).toMatch(/\b33\b/u);
expect(content).toMatch(/\b10\b/u);
expect(content).toMatch(/\b3\b/u);
},
);

View File

@ -10,7 +10,7 @@ const fixturePath = fileURLToPath(
);
describe("production stdio runtime", () => {
it("starts the built entrypoint, lists 42 tools, calls one, and shuts down", async () => {
it("starts the built entrypoint, lists 46 tools, calls one, and shuts down", async () => {
const transport = new StdioClientTransport({
command: process.execPath,
args: [mainPath],
@ -32,7 +32,7 @@ describe("production stdio runtime", () => {
try {
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools).toHaveLength(42);
expect(tools.tools).toHaveLength(46);
const result = await client.callTool({
name: "current_document",
arguments: {},
@ -78,7 +78,7 @@ describe("production stdio runtime", () => {
try {
await client.connect(transport);
expect((await client.listTools()).tools).toHaveLength(42);
expect((await client.listTools()).tools).toHaveLength(46);
} finally {
await client.close();
await transport.close();

View File

@ -5,10 +5,8 @@ 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 { BinarySession } from "../src/application/BinarySession.js";
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
import { ok } from "../src/domain/result.js";
import { createServer } from "../src/server/createServer.js";
@ -44,7 +42,7 @@ describe("target-free MCP lifecycle", () => {
});
expect(before.isError).toBe(true);
expect(text(before)).toContain("NoBinaryOpenError");
expect((await mcp.listTools()).tools).toHaveLength(42);
expect((await mcp.listTools()).tools).toHaveLength(46);
expect(
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
.isError,
@ -64,9 +62,11 @@ describe("target-free MCP lifecycle", () => {
});
});
const client = (path: string, closed: string[]): BinaryClient => ({
callTool: (name) =>
Promise.resolve(ok(name === "health" ? null : { path, name })),
const client = (path: string, closed: string[]): AnalysisClient => ({
execute: (name) =>
Promise.resolve(
ok(name === "health" ? null : name === "current_document" ? path : null),
),
close: () => {
closed.push(path);
return Promise.resolve();

View File

@ -8,16 +8,16 @@ import {
} from "../src/contracts/toolContracts.js";
describe("tool contract inventory", () => {
it("preserves the original 39 contracts and adds three session tools", () => {
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31);
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8);
it("publishes 43 analysis contracts and three session tools", () => {
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(33);
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(10);
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);
expect(TOOL_CONTRACTS).toHaveLength(46);
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(46);
});
it("retains documented enhanced-tool limits at the input boundary", () => {