diff --git a/README.md b/README.md
index eb3fc7cff..55d702634 100644
--- a/README.md
+++ b/README.md
@@ -10,11 +10,11 @@
[](https://www.npmjs.com/package/@morluto/rea)
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
-[](#42-tools-for-deeper-investigation)
+[](#46-tools-for-deeper-investigation)
[](https://nodejs.org/)
[](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)
@@ -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
diff --git a/README_ar.md b/README_ar.md
index 8f416a082..5d756a756 100644
--- a/README_ar.md
+++ b/README_ar.md
@@ -10,11 +10,11 @@
[](https://www.npmjs.com/package/@morluto/rea)
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
-[](#منصة-من-42-أداة)
+[](#منصة-من-46-أداة)
[](https://nodejs.org/)
[](LICENSE)
-[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [42 أداة](#منصة-من-42-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
+[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [46 أداة](#منصة-من-46-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
@@ -145,12 +145,12 @@ npx -y @morluto/rea doctor
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
-## منصة من 42 أداة
+## منصة من 46 أداة
| عائلة الأدوات | العدد | أمثلة |
| --------------------- | ----: | ----------------------------------------------------------------------------------- |
-| فحص الملفات التنفيذية | 31 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
-| التحليل المركب | 8 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
+| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
+| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| جلسة الملف التنفيذي | 3 | `open_binary` و`binary_session` و`close_binary` |
## استخدام REA مع وكلاء برمجة آخرين
diff --git a/README_ja.md b/README_ja.md
index 5e25816b1..8dc17f379 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -10,11 +10,11 @@
[](https://www.npmjs.com/package/@morluto/rea)
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
-[](#42-ツールのワークベンチ)
+[](#46-ツールのワークベンチ)
[](https://nodejs.org/)
[](LICENSE)
-[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [42 ツール](#42-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
+[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [46 ツール](#46-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
@@ -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` |
## 他のコーディングエージェントで使う
diff --git a/README_ko.md b/README_ko.md
index 9d4273475..4eaeae68a 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -10,11 +10,11 @@
[](https://www.npmjs.com/package/@morluto/rea)
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
-[](#42개-도구로-구성된-워크벤치)
+[](#46개-도구로-구성된-워크벤치)
[](https://nodejs.org/)
[](LICENSE)
-[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [42개 도구](#42개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
+[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [46개 도구](#46개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
@@ -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` |
## 다른 코딩 에이전트에서 사용하기
diff --git a/README_zh.md b/README_zh.md
index d0f3dc6a8..c6af61880 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -10,11 +10,11 @@
[](https://www.npmjs.com/package/@morluto/rea)
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
-[](#42-个工具组成的工作台)
+[](#46-个工具组成的工作台)
[](https://nodejs.org/)
[](LICENSE)
-[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [42 个工具](#42-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
+[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [46 个工具](#46-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
@@ -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` |
## 与其他编程智能体一起使用
diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py
index 0faf5fa04..dbb28def2 100644
--- a/bridge/hopper_bridge.py
+++ b/bridge/hopper_bridge.py
@@ -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)
diff --git a/package.json b/package.json
index 466e87615..187b0c8c6 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/build-conformance-fixtures.mjs b/scripts/build-conformance-fixtures.mjs
new file mode 100644
index 000000000..11b4f0889
--- /dev/null
+++ b/scripts/build-conformance-fixtures.mjs
@@ -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,
+ });
+}
diff --git a/scripts/lib/conformance-fixtures.d.mts b/scripts/lib/conformance-fixtures.d.mts
new file mode 100644
index 000000000..949e300ce
--- /dev/null
+++ b/scripts/lib/conformance-fixtures.d.mts
@@ -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;
diff --git a/scripts/lib/conformance-fixtures.mjs b/scripts/lib/conformance-fixtures.mjs
new file mode 100644
index 000000000..64952dadb
--- /dev/null
+++ b/scripts/lib/conformance-fixtures.mjs
@@ -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 ",
+ ...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);
+};
diff --git a/scripts/rea.mjs b/scripts/rea.mjs
index e7498d5d2..52cf67333 100755
--- a/scripts/rea.mjs
+++ b/scripts/rea.mjs
@@ -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");
diff --git a/scripts/verify-conformance-fixtures.mjs b/scripts/verify-conformance-fixtures.mjs
new file mode 100644
index 000000000..7cf09addc
--- /dev/null
+++ b/scripts/verify-conformance-fixtures.mjs
@@ -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}`,
+ );
+}
diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs
index c873ff6ac..b20e11604 100644
--- a/scripts/verify-package.mjs
+++ b/scripts/verify-package.mjs
@@ -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 });
diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs
index dc14b8227..e7cbb5678 100644
--- a/scripts/verify-real-hopper.mjs
+++ b/scripts/verify-real-hopper.mjs
@@ -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,
diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts
new file mode 100644
index 000000000..6e00ea2c0
--- /dev/null
+++ b/src/application/AnalysisProvider.ts
@@ -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>,
+ options?: ExecutionOptions,
+ ): Promise>;
+}
+
+/** Provider session bound to exactly one parsed artifact. */
+export interface AnalysisClient extends AnalysisOperationPort {
+ close(): Promise;
+}
+
+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;
+}
diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts
index 37601f4a7..53a1688b6 100644
--- a/src/application/BinarySession.ts
+++ b/src/application/BinarySession.ts
@@ -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;
+/** 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>;
+ close(): Promise>;
+ 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 = Promise.resolve();
readonly #calls = new Set>();
- 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> {
+ ): Promise> {
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> {
+ close(): Promise> {
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>,
options?: { readonly signal?: AbortSignal },
- ): Promise> {
+ ): Promise> {
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 {
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();
}
diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts
index 6f06e7932..e280b0d4b 100644
--- a/src/application/DirectAnalysis.ts
+++ b/src/application/DirectAnalysis.ts
@@ -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();
diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts
index 7e2d4b6c0..81201cbe2 100644
--- a/src/application/EnhancedTools.ts
+++ b/src/application/EnhancedTools.ts
@@ -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>;
+type EnhancedResult = Promise>;
+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>;
* 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();
+ 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();
+ 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>,
signal?: AbortSignal,
- ): Promise> {
- return this.hopper.callTool(
+ ): Promise> {
+ 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));
diff --git a/src/application/HopperToolPort.ts b/src/application/HopperToolPort.ts
deleted file mode 100644
index be9ebac12..000000000
--- a/src/application/HopperToolPort.ts
+++ /dev/null
@@ -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>,
- options?: { readonly signal?: AbortSignal },
- ): Promise>;
-}
diff --git a/src/application/runtime.ts b/src/application/runtime.ts
index bed304462..c49580b17 100644
--- a/src/application/runtime.ts
+++ b/src/application/runtime.ts
@@ -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));
};
diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts
index 4a60a1844..8238ae7ea 100644
--- a/src/contracts/enhancedInputs.ts
+++ b/src/contracts/enhancedInputs.ts
@@ -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",
]);
diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts
index a606eb419..51708bc00 100644
--- a/src/contracts/toolContracts.ts
+++ b/src/contracts/toolContracts.ts
@@ -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>,
+ 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 = [
diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts
new file mode 100644
index 000000000..2f97380e6
--- /dev/null
+++ b/src/contracts/toolOutputSchemas.ts
@@ -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> = {
+ 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> = {
+ 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> = {
+ 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(),
+ }),
+ ]),
+ ),
+};
diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts
index 306fd4a3b..749193fd4 100644
--- a/src/domain/binaryTarget.ts
+++ b/src/domain/binaryTarget.ts
@@ -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;
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 => {
+ const hash = createHash("sha256");
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
+ return hash.digest("hex");
+};
+
const resolveAppBundle = async (
path: string,
): Promise> => {
diff --git a/src/domain/errors.ts b/src/domain/errors.ts
index b7dd9514b..04924a470 100644
--- a/src/domain/errors.ts
+++ b/src/domain/errors.ts
@@ -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,
diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts
new file mode 100644
index 000000000..6c456bcf8
--- /dev/null
+++ b/src/domain/evidence.ts
@@ -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;
+
+export interface EvidenceObservation {
+ readonly operation: string;
+ readonly parameters: Readonly>;
+ 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 ?? [])],
+ });
diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts
index adc28b017..5f26c034f 100644
--- a/src/domain/hopperValues.ts
+++ b/src/domain/hopperValues.ts
@@ -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 => {
+ 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 => {
+ 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 => {
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 => {
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);
diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts
new file mode 100644
index 000000000..5abc86806
--- /dev/null
+++ b/src/domain/jsonValue.ts
@@ -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;
diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts
index c47aebb0b..1b4ba0508 100644
--- a/src/domain/symbolAnalysis.ts
+++ b/src/domain/symbolAnalysis.ts
@@ -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;
diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts
new file mode 100644
index 000000000..65208fc7a
--- /dev/null
+++ b/src/hopper/HopperProvider.ts
@@ -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(),
+ };
+ }
+}
diff --git a/src/hopper/protocol.ts b/src/hopper/protocol.ts
index eb1852e2a..4b2ce11e2 100644
--- a/src/hopper/protocol.ts
+++ b/src/hopper/protocol.ts
@@ -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;
+export { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
const responseSchema = z.union([
z.object({
diff --git a/src/server/createServer.ts b/src/server/createServer.ts
index 9e4a08f31..cbcbafbe1 100644
--- a/src/server/createServer.ts
+++ b/src/server/createServer.ts
@@ -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;
};
diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts
index ab4263950..af1cb133f 100644
--- a/src/server/registerEnhancedTools.ts
+++ b/src/server/registerEnhancedTools.ts
@@ -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 = { ...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,
+ );
+ },
);
};
diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts
index 42b011ebe..17d28cd83 100644
--- a/src/server/registerOfficialTools.ts
+++ b/src/server/registerOfficialTools.ts
@@ -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);
},
);
};
diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts
index 50c361906..611772af9 100644
--- a/src/server/registerSessionTools.ts
+++ b/src/server/registerSessionTools.ts
@@ -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),
);
};
diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts
index 331f61c41..8a36f7071 100644
--- a/src/server/toolResult.ts
+++ b/src/server/toolResult.ts
@@ -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,
+ result: Result,
+ 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;
diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts
index e96eb3cf1..c149cfa3b 100644
--- a/tests/binarySession.test.ts
+++ b/tests/binarySession.test.ts
@@ -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>>,
readonly failHealth = false,
readonly pendingCall?: Promise>>,
) {}
- callTool(name: string) {
+ execute(name: string) {
if (name === "health")
return this.failHealth
? Promise.resolve(err(new HopperStartError()))
diff --git a/tests/binaryTarget.test.ts b/tests/binaryTarget.test.ts
index 9ff7ece05..baaa4a07a 100644
--- a/tests/binaryTarget.test.ts
+++ b/tests/binaryTarget.test.ts
@@ -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);
});
diff --git a/tests/bridgeTruthfulness.test.ts b/tests/bridgeTruthfulness.test.ts
new file mode 100644
index 000000000..2e5a6cf37
--- /dev/null
+++ b/tests/bridgeTruthfulness.test.ts
@@ -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",
+ );
+ });
+});
diff --git a/tests/conformance/README.md b/tests/conformance/README.md
new file mode 100644
index 000000000..88d64f28a
--- /dev/null
+++ b/tests/conformance/README.md
@@ -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.
diff --git a/tests/conformance/c/fixture.c b/tests/conformance/c/fixture.c
new file mode 100644
index 000000000..38cfc2df3
--- /dev/null
+++ b/tests/conformance/c/fixture.c
@@ -0,0 +1,19 @@
+#include
+
+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; }
diff --git a/tests/conformance/napi/fixture.c b/tests/conformance/napi/fixture.c
new file mode 100644
index 000000000..c78c9f7f4
--- /dev/null
+++ b/tests/conformance/napi/fixture.c
@@ -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;
+}
diff --git a/tests/conformance/objc/fixture.m b/tests/conformance/objc/fixture.m
new file mode 100644
index 000000000..bc9f5ea2d
--- /dev/null
+++ b/tests/conformance/objc/fixture.m
@@ -0,0 +1,21 @@
+#import
+@protocol REAWidgetDelegate
+- (void)widgetDidFinish;
+@end
+@interface REAWidget : NSObject
+@property(nonatomic, weak) id 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;
+ }
+}
diff --git a/tests/conformance/swift/fixture.swift b/tests/conformance/swift/fixture.swift
new file mode 100644
index 000000000..f7d9e0a9c
--- /dev/null
+++ b/tests/conformance/swift/fixture.swift
@@ -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)
diff --git a/tests/conformance/versions/v1.c b/tests/conformance/versions/v1.c
new file mode 100644
index 000000000..f9d2162bd
--- /dev/null
+++ b/tests/conformance/versions/v1.c
@@ -0,0 +1,9 @@
+#include
+__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; }
diff --git a/tests/conformance/versions/v2.c b/tests/conformance/versions/v2.c
new file mode 100644
index 000000000..8d5828652
--- /dev/null
+++ b/tests/conformance/versions/v2.c
@@ -0,0 +1,10 @@
+#include
+__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; }
diff --git a/tests/conformanceFixtures.test.ts b/tests/conformanceFixtures.test.ts
new file mode 100644
index 000000000..6b02b9ec0
--- /dev/null
+++ b/tests/conformanceFixtures.test.ts
@@ -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"));
+ });
+});
diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts
index 59cdbe570..eaadfcfc7 100644
--- a/tests/contractSnapshot.test.ts
+++ b/tests/contractSnapshot.test.ts
@@ -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);
+ }
+ });
});
diff --git a/tests/createServer.test.ts b/tests/createServer.test.ts
index 47c3da51f..2aacfc3c3 100644
--- a/tests/createServer.test.ts
+++ b/tests/createServer.test.ts
@@ -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();
diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts
index 560f5d59b..003b3cbf3 100644
--- a/tests/enhancedTools.test.ts
+++ b/tests/enhancedTools.test.ts
@@ -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>) => ({
+ 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 }> = [];
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",
diff --git a/tests/evidence.test.ts b/tests/evidence.test.ts
new file mode 100644
index 000000000..336be8baa
--- /dev/null
+++ b/tests/evidence.test.ts
@@ -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.",
+ ],
+ });
+ });
+});
diff --git a/tests/fixtures/fakeHopper.mjs b/tests/fixtures/fakeHopper.mjs
index 64cfa4121..84f63216a 100644
--- a/tests/fixtures/fakeHopper.mjs
+++ b/tests/fixtures/fakeHopper.mjs
@@ -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(
diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts
index a83c061fe..2793b94b2 100644
--- a/tests/hopperClient.test.ts
+++ b/tests/hopperClient.test.ts
@@ -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);
});
diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts
index 9d3bd7c66..d3bb017b0 100644
--- a/tests/integration/serverIntegration.test.ts
+++ b/tests/integration/serverIntegration.test.ts
@@ -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 => {
+ 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,
+ }
+ : [],
+ ),
+ );
},
});
diff --git a/tests/officialTools.test.ts b/tests/officialTools.test.ts
index 9afe4f249..c5d137a4d 100644
--- a/tests/officialTools.test.ts
+++ b/tests/officialTools.test.ts
@@ -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",
diff --git a/tests/providerArchitecture.test.ts b/tests/providerArchitecture.test.ts
new file mode 100644
index 000000000..11b0468e2
--- /dev/null
+++ b/tests/providerArchitecture.test.ts
@@ -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 => {
+ 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();
+};
diff --git a/tests/readmeConsistency.test.ts b/tests/readmeConsistency.test.ts
index 39b59076c..deef52771 100644
--- a/tests/readmeConsistency.test.ts
+++ b/tests/readmeConsistency.test.ts
@@ -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);
},
);
diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts
index 342b30636..cab5653e1 100644
--- a/tests/runtime.test.ts
+++ b/tests/runtime.test.ts
@@ -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();
diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts
index d84413a7c..552b6bb9c 100644
--- a/tests/sessionMcp.test.ts
+++ b/tests/sessionMcp.test.ts
@@ -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();
diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts
index d7bc2448b..5af7630f4 100644
--- a/tests/toolContracts.test.ts
+++ b/tests/toolContracts.test.ts
@@ -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", () => {