Initial commit: betterBinaryMCP - Enhanced MCP server for Hopper Disassembler

39 tools total:
- 31 official Hopper 6 tools proxied via STDIO/NDJSON
- 8 RE-specific tools: swift_classes, get_objc_classes,
  get_objc_protocols, batch_decompile, get_call_graph,
  analyze_swift_types, find_xrefs_to_name, binary_overview

No Python injection needed - wraps official HopperMCPServer binary.
This commit is contained in:
morluto
2026-04-14 08:51:30 +08:00
commit 729d3e927c
9 changed files with 1282 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Byte-compiled
__pycache__/
*.py[cod]
*$py.class
# Distribution
dist/
build/
*.egg-info/
*.egg
# Virtual environments
.venv/
venv/
env/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Testing
.pytest_cache/
htmlcov/
.coverage
# OS
.DS_Store
Thumbs.db
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 morluto
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+115
View File
@@ -0,0 +1,115 @@
# betterBinaryMCP
An enhanced MCP (Model Context Protocol) server for [Hopper Disassembler](https://www.hopperapp.com/) that wraps the official Hopper 6 MCP server and adds reverse engineering–specific analysis tools.
**No Python injection or plugins needed** — just Hopper open with a binary.
## Architecture
```
MCP Client ←STDIO→ betterBinaryMCP ←STDIO→ Official HopperMCPServer ←XPC→ Hopper.app
```
- Wraps the official `HopperMCPServer` binary as a subprocess
- Proxies all 31 official tools unchanged
- Adds 8 RE-specific tools built on top of official operations
## Tools
### 31 Official Tools (proxied)
| Tool | Description |
|------|-------------|
| `address_name` | Get/set name (label) at an address |
| `comment` / `inline_comment` | Get/set comments |
| `current_address` / `current_procedure` / `current_document` | Cursor state |
| `goto_address` / `next_address` / `prev_address` | Navigation |
| `list_segments` / `list_procedures` / `list_strings` / `list_names` / `list_documents` / `list_bookmarks` | Listing |
| `search_procedures` / `search_strings` | Regex search |
| `procedure_pseudo_code` / `procedure_assembly` | Decompilation |
| `procedure_callees` / `procedure_callers` | Call relationships |
| `procedure_info` / `procedure_address` | Procedure metadata |
| `set_address_name` / `set_addresses_names` | Rename labels |
| `set_comment` / `set_inline_comment` | Write comments |
| `set_bookmark` / `unset_bookmark` | Bookmarks |
| `set_current_document` | Switch active document |
| `xrefs` | Cross-references |
### 8 RE-Specific Tools (added)
| Tool | Description |
|------|-------------|
| `binary_overview()` | Segment layout, procedure/string counts, document info |
| `swift_classes(pattern)` | Swift class hierarchy from mangled `_TtC` names |
| `get_objc_classes(pattern)` | Objective-C class names from labels |
| `get_objc_protocols()` | Objective-C protocol names from labels |
| `batch_decompile(addresses)` | Decompile up to 20 procedures at once |
| `get_call_graph(address, direction, depth)` | Recursive call graph traversal (forward/backward) |
| `analyze_swift_types()` | Categorize all Swift mangled names by module/type/method |
| `find_xrefs_to_name(name)` | Find references by name lookup |
## Requirements
- **Hopper Disassembler** v6+ (ships the official MCP server)
- **Python** 3.10+
- **Hopper open** with a binary loaded and analyzed
## Installation
```bash
pip install fastmcp
```
## Usage
### As an MCP Server (recommended)
Add to your MCP client configuration (Claude Desktop, Cursor, etc.):
```json
{
"mcpServers": {
"betterBinaryMCP": {
"command": "python3",
"args": ["/path/to/betterBinaryMCP/server.py"]
}
}
}
```
### Command Line
```bash
python3 server.py
```
### Test Connection
```bash
python3 hopper_client.py
```
## Testing
```bash
pip install pytest pytest-asyncio
python -m pytest tests/ -v
```
## How It Works
1. On startup, the server launches the official `HopperMCPServer` binary as a subprocess
2. Communicates via NDJSON (newline-delimited JSON) over STDIO — no HTTP, no Content-Length framing
3. Proxied tools forward calls directly to the official server
4. RE tools compose multiple official calls (e.g., `swift_classes` calls `list_procedures` and parses `_TtC` mangled names)
## Limitations
- Requires Hopper 6+ (the official MCP server binary must exist)
- The official server connects to Hopper via XPC — Hopper must be running with a document open
- `batch_decompile` is limited to 20 procedures per call
- `get_call_graph` is limited to depth 5
## License
MIT
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
NDJSON client for the official HopperMCPServer subprocess.
Handles STDIO communication using NDJSON (newline-delimited JSON) protocol.
Each message is a single JSON object followed by a newline.
"""
import json
import subprocess
import select
import threading
import time
from typing import Any, Optional
class HopperMCPSubprocessClient:
"""Client for communicating with HopperMCPServer via STDIO/NDJSON."""
def __init__(self, server_path: str = "/Applications/Hopper Disassembler.app/Contents/MacOS/HopperMCPServer"):
self.server_path = server_path
self._proc: Optional[subprocess.Popen] = None
self._next_id = 1
self._lock = threading.Lock()
self._server_info: dict = {}
def start(self) -> dict:
"""Start the subprocess and initialize the connection."""
if self._proc is not None:
return self._server_info
self._proc = subprocess.Popen(
[self.server_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0
)
self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True)
self._stderr_thread.start()
time.sleep(0.5)
self._send({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "hopper-proxy", "version": "1.0"}
}
})
response = self._recv(timeout=10.0)
if response and "result" in response:
self._server_info = response.get("result", {}).get("serverInfo", {})
else:
raise RuntimeError(f"Failed to initialize server: {response}")
time.sleep(1.0)
return self._server_info
def _drain_stderr(self):
"""Drain stderr in background thread."""
while self._proc and self._proc.poll() is None:
try:
ready = select.select([self._proc.stderr], [], [], 0.5)
if ready[0]:
line = self._proc.stderr.readline()
if not line:
break
except Exception:
break
def stop(self):
"""Stop the subprocess."""
if self._proc:
self._proc.terminate()
self._proc.wait(timeout=5)
self._proc = None
def _send(self, msg: dict) -> int:
"""Send a JSON-RPC message."""
with self._lock:
self._next_id += 1
msg["id"] = self._next_id
line = json.dumps(msg) + "\n"
self._proc.stdin.write(line.encode("utf-8"))
self._proc.stdin.flush()
return self._next_id
def _recv(self, timeout: float = 30.0) -> Optional[dict]:
"""Receive a JSON-RPC response."""
ready = select.select([self._proc.stdout], [], [], timeout)
if ready[0]:
line = self._proc.stdout.readline()
if line:
try:
return json.loads(line.decode("utf-8"))
except json.JSONDecodeError:
return None
return None
def call_tool(self, tool_name: str, arguments: dict = None) -> Any:
"""Call a tool on the server."""
arguments = arguments or {}
self._send({
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments
}
})
response = self._recv(timeout=30.0)
if response and "error" in response:
error_msg = response["error"].get("message", str(response["error"]))
raise RuntimeError(f"Tool '{tool_name}' error: {error_msg}")
if response and "result" in response:
content = response["result"].get("content", [])
for c in content:
if c.get("type") == "text":
try:
return json.loads(c["text"])
except (json.JSONDecodeError, TypeError):
return c["text"]
return response["result"]
return response
@property
def server_info(self) -> dict:
return self._server_info
def test_connection():
"""Test connection to the official server."""
client = HopperMCPSubprocessClient()
info = client.start()
print(f"Connected to {info.get('name', '?')} v{info.get('version', '?')}")
docs = client.call_tool("list_documents", {})
print(f"Documents: {docs}")
client.stop()
if __name__ == "__main__":
test_connection()
+21
View File
@@ -0,0 +1,21 @@
[project]
name = "betterBinaryMCP"
version = "0.1.0"
description = "Enhanced MCP server for Hopper Disassembler with reverse engineering tools"
requires-python = ">=3.10"
dependencies = [
"fastmcp>=0.1.0",
]
[project.optional-dependencies]
test = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
]
[project.scripts]
better-binary-mcp = "HopperProxyMCP.server:main"
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.backends._legacy:_Backend"
+3
View File
@@ -0,0 +1,3 @@
fastmcp
pytest
pytest-asyncio
+644
View File
@@ -0,0 +1,644 @@
#!/usr/bin/env python3
"""
HopperProxyMCP - Proxy server that wraps the official HopperMCPServer and adds RE-specific tools.
Architecture:
MCP Client ←STDIO→ Our Proxy Server ←STDIO→ Official HopperMCPServer ←XPC→ Hopper.app
"""
import asyncio
import json
from contextlib import asynccontextmanager
from typing import Any, Optional
from fastmcp import FastMCP, Context
from pydantic import Field
try:
from hopper_client import HopperMCPSubprocessClient
except ImportError:
from .hopper_client import HopperMCPSubprocessClient
OFFICIAL_SERVER_PATH = "/Applications/Hopper Disassembler.app/Contents/MacOS/HopperMCPServer"
_client: Optional[HopperMCPSubprocessClient] = None
def get_client() -> HopperMCPSubprocessClient:
global _client
if _client is None:
_client = HopperMCPSubprocessClient(OFFICIAL_SERVER_PATH)
_client.start()
return _client
@asynccontextmanager
async def lifespan(server: FastMCP):
global _client
_client = HopperMCPSubprocessClient(OFFICIAL_SERVER_PATH)
_client.start()
yield
if _client:
_client.stop()
_client = None
mcp = FastMCP(
name="HopperProxyMCP",
instructions=(
"This server provides reverse engineering tools for analyzing binaries in Hopper Disassembler. "
"It wraps the official Hopper MCP server and adds Swift/ObjC-specific analysis tools. "
"Use binary_overview() first to understand the loaded binary, then swift_classes() or "
"analyze_swift_types() to explore the type hierarchy, and batch_decompile() or "
"get_call_graph() for deeper analysis."
),
version="1.0.0",
mask_error_details=True,
on_duplicate_tools="error",
lifespan=lifespan,
)
def _call_official(tool_name: str, arguments: dict = None) -> Any:
return get_client().call_tool(tool_name, arguments or {})
@mcp.tool(name="address_name")
async def address_name_tool(
document: Optional[str] = Field(default=None, description="The document name"),
address: Optional[str] = Field(default=None, description="The address to get the name for"),
) -> str:
result = _call_official("address_name", {"document": document, "address": address})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="comment")
async def comment_tool(
document: Optional[str] = Field(default=None, description="The document name"),
address: Optional[str] = Field(default=None, description="The address to get the comment for"),
) -> str:
result = _call_official("comment", {"document": document, "address": address})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="current_address")
async def current_address_tool(
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("current_address", {"document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="current_procedure")
async def current_procedure_tool(
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("current_procedure", {"document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="current_document")
async def current_document_tool() -> str:
result = _call_official("current_document", {})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="goto_address")
async def goto_address_tool(
address: str = Field(description="The address to go to"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("goto_address", {"address": address, "document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="inline_comment")
async def inline_comment_tool(
document: Optional[str] = Field(default=None, description="The document name"),
address: Optional[str] = Field(default=None, description="The address to get the inline comment for"),
) -> str:
result = _call_official("inline_comment", {"document": document, "address": address})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="list_bookmarks")
async def list_bookmarks_tool(
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("list_bookmarks", {"document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="list_documents")
async def list_documents_tool() -> str:
result = _call_official("list_documents", {})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="list_names")
async def list_names_tool(
document: Optional[str] = Field(default=None, description="The document name"),
address: Optional[str] = Field(default=None, description="The address to start from"),
) -> str:
result = _call_official("list_names", {"document": document, "address": address})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="list_procedures")
async def list_procedures_tool(
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("list_procedures", {"document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="list_segments")
async def list_segments_tool(
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("list_segments", {"document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="list_strings")
async def list_strings_tool(
document: Optional[str] = Field(default=None, description="The document name"),
address: Optional[str] = Field(default=None, description="The address to start from"),
) -> str:
result = _call_official("list_strings", {"document": document, "address": address})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="next_address")
async def next_address_tool(
document: Optional[str] = Field(default=None, description="The document name"),
address: Optional[str] = Field(default=None, description="The current address"),
) -> str:
result = _call_official("next_address", {"document": document, "address": address})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="prev_address")
async def prev_address_tool(
document: Optional[str] = Field(default=None, description="The document name"),
address: Optional[str] = Field(default=None, description="The current address"),
) -> str:
result = _call_official("prev_address", {"document": document, "address": address})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="procedure_address")
async def procedure_address_tool(
procedure: str = Field(description="The procedure name or address"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("procedure_address", {"procedure": procedure, "document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="procedure_assembly")
async def procedure_assembly_tool(
procedure: str = Field(description="The procedure name or address"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("procedure_assembly", {"procedure": procedure, "document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="procedure_callees")
async def procedure_callees_tool(
procedure: str = Field(description="The procedure name or address"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("procedure_callees", {"procedure": procedure, "document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
elif isinstance(result, list):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="procedure_callers")
async def procedure_callers_tool(
procedure: str = Field(description="The procedure name or address"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("procedure_callers", {"procedure": procedure, "document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
elif isinstance(result, list):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="procedure_info")
async def procedure_info_tool(
procedure: str = Field(description="The procedure name or address"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("procedure_info", {"procedure": procedure, "document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="procedure_pseudo_code")
async def procedure_pseudo_code_tool(
procedure: str = Field(description="The procedure name or address"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("procedure_pseudo_code", {"procedure": procedure, "document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="search_procedures")
async def search_procedures_tool(
pattern: str = Field(description="The regex pattern to search for"),
case_sensitive: bool = Field(default=False, description="Whether to match case"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("search_procedures", {"pattern": pattern, "case_sensitive": case_sensitive, "document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="search_strings")
async def search_strings_tool(
pattern: str = Field(description="The regex pattern to search for"),
case_sensitive: bool = Field(default=False, description="Whether to match case"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("search_strings", {"pattern": pattern, "case_sensitive": case_sensitive, "document": document})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="set_address_name")
async def set_address_name_tool(
address: str = Field(description="The address to name"),
name: str = Field(description="The name to set"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("set_address_name", {"address": address, "name": name, "document": document})
return "OK" if result else "OK"
@mcp.tool(name="set_addresses_names")
async def set_addresses_names_tool(
names: dict = Field(description="A dictionary mapping addresses to names"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("set_addresses_names", {"names": names, "document": document})
return "OK" if result else "OK"
@mcp.tool(name="set_bookmark")
async def set_bookmark_tool(
address: str = Field(description="The address to bookmark"),
name: Optional[str] = Field(default=None, description="The bookmark name"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("set_bookmark", {"address": address, "name": name, "document": document})
return "OK" if result else "OK"
@mcp.tool(name="set_comment")
async def set_comment_tool(
address: str = Field(description="The address to comment on"),
comment: str = Field(description="The comment text"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("set_comment", {"address": address, "comment": comment, "document": document})
return "OK" if result else "OK"
@mcp.tool(name="set_current_document")
async def set_current_document_tool(
document: str = Field(description="The document name to set as current"),
) -> str:
result = _call_official("set_current_document", {"document": document})
return "OK" if result else "OK"
@mcp.tool(name="set_inline_comment")
async def set_inline_comment_tool(
address: str = Field(description="The address to inline comment on"),
comment: str = Field(description="The inline comment text"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("set_inline_comment", {"address": address, "comment": comment, "document": document})
return "OK" if result else "OK"
@mcp.tool(name="unset_bookmark")
async def unset_bookmark_tool(
address: str = Field(description="The address to remove bookmark from"),
document: Optional[str] = Field(default=None, description="The document name"),
) -> str:
result = _call_official("unset_bookmark", {"address": address, "document": document})
return "OK" if result else "OK"
@mcp.tool(name="xrefs")
async def xrefs_tool(
document: Optional[str] = Field(default=None, description="The document name"),
address: Optional[str] = Field(default=None, description="The address to get xrefs for"),
) -> str:
result = _call_official("xrefs", {"document": document, "address": address})
if isinstance(result, dict):
return json.dumps(result, indent=2, default=str)
elif isinstance(result, list):
return json.dumps(result, indent=2, default=str)
return str(result) if result else "OK"
@mcp.tool(name="swift_classes")
async def swift_classes_tool(
pattern: str = Field(default="", description="Optional pattern to filter class names"),
) -> str:
all_procs = _call_official("list_procedures", {}) or {}
if isinstance(all_procs, dict):
all_procs = all_procs.get("procedures", all_procs)
swift_classes = []
for addr, name in list(all_procs.items())[:5000] if isinstance(all_procs, dict) else []:
if not isinstance(name, str):
continue
if name.startswith("_TtC") or "_TtC" in name:
if pattern and pattern not in name:
continue
swift_classes.append({"address": addr, "name": name})
result = {
"count": len(swift_classes),
"classes": swift_classes[:100],
}
return json.dumps(result, indent=2, default=str)
@mcp.tool(name="get_objc_classes")
async def get_objc_classes_tool(
pattern: str = Field(default="", description="Optional pattern to filter class names"),
) -> str:
names = _call_official("list_names", {}) or {}
if isinstance(names, dict):
names = names.get("names", names)
objc_classes = []
seen = set()
for item in names if isinstance(names, list) else []:
if not isinstance(item, dict):
continue
name = item.get("name", "")
if not isinstance(name, str):
continue
if "OBJC_CLASS" in name or "OBJC_$_CLASS" in name or name.startswith("_OBJC_"):
if pattern and pattern not in name:
continue
addr = item.get("address", "")
if name not in seen:
seen.add(name)
objc_classes.append({"address": addr, "name": name})
result = {
"count": len(objc_classes),
"classes": objc_classes[:100],
}
return json.dumps(result, indent=2, default=str)
@mcp.tool(name="get_objc_protocols")
async def get_objc_protocols_tool() -> str:
names = _call_official("list_names", {}) or {}
if isinstance(names, dict):
names = names.get("names", names)
protocols = []
seen = set()
for item in names if isinstance(names, list) else []:
if not isinstance(item, dict):
continue
name = item.get("name", "")
if not isinstance(name, str):
continue
if "OBJC_PROTOCOL" in name or "_TtP" in name:
if name not in seen:
seen.add(name)
protocols.append({"address": item.get("address", ""), "name": name})
result = {
"count": len(protocols),
"protocols": protocols[:100],
}
return json.dumps(result, indent=2, default=str)
@mcp.tool(name="batch_decompile")
async def batch_decompile_tool(
addresses: list[str] = Field(default=[], description="List of procedure addresses to decompile"),
) -> str:
if not addresses:
return json.dumps({"error": "No addresses provided"}, indent=2)
addresses = addresses[:20]
results = {}
for addr in addresses:
try:
pseudo = _call_official("procedure_pseudo_code", {"procedure": addr})
results[addr] = pseudo if pseudo else "No output"
except Exception as e:
results[addr] = f"Error: {str(e)}"
return json.dumps(results, indent=2, default=str)
@mcp.tool(name="get_call_graph")
async def get_call_graph_tool(
address: str = Field(description="Starting address for call graph traversal"),
direction: str = Field(default="forward", description="Traversal direction: 'forward' (callees) or 'backward' (callers)"),
depth: int = Field(default=2, description="Maximum traversal depth (1-5)"),
) -> str:
depth = min(max(1, depth), 5)
visited = set()
queue = [(address, 0)]
graph = {}
while queue:
current, current_depth = queue.pop(0)
if current in visited or current_depth >= depth:
continue
visited.add(current)
if current_depth not in graph:
graph[current_depth] = []
tool_name = "procedure_callees" if direction == "forward" else "procedure_callers"
try:
callees = _call_official(tool_name, {"procedure": current}) or []
if isinstance(callees, dict):
callees = callees.get("callees", callees.get("callers", []))
graph[current_depth].append({"address": current, "calls": callees})
if current_depth + 1 < depth:
for callee in callees if isinstance(callees, list) else []:
if callee not in visited:
queue.append((callee, current_depth + 1))
except Exception as e:
graph[current_depth].append({"address": current, "error": str(e)})
return json.dumps(graph, indent=2, default=str)
@mcp.tool(name="analyze_swift_types")
async def analyze_swift_types_tool() -> str:
all_procs = _call_official("list_procedures", {}) or {}
if isinstance(all_procs, dict):
all_procs = all_procs.get("procedures", all_procs)
swift_symbols = {
"classes": [],
"structs": [],
"enums": [],
"protocols": [],
"extensions": [],
"other": [],
}
seen = set()
for addr, name in list(all_procs.items())[:5000] if isinstance(all_procs, dict) else []:
if not isinstance(name, str) or "_Tt" not in name:
continue
if name in seen:
continue
seen.add(name)
entry = {"address": addr, "name": name}
if name.startswith("_TtC"):
swift_symbols["classes"].append(entry)
elif name.startswith("_TtV"):
swift_symbols["structs"].append(entry)
elif name.startswith("_TtO"):
swift_symbols["enums"].append(entry)
elif name.startswith("_TtP"):
swift_symbols["protocols"].append(entry)
elif name.startswith("_TtE"):
swift_symbols["extensions"].append(entry)
else:
swift_symbols["other"].append(entry)
result = {
"total": sum(len(v) for v in swift_symbols.values()),
"categories": {k: {"count": len(v), "items": v[:50]} for k, v in swift_symbols.items()},
}
return json.dumps(result, indent=2, default=str)
@mcp.tool(name="find_xrefs_to_name")
async def find_xrefs_to_name_tool(
name: str = Field(description="Name to search for and get xrefs to"),
) -> str:
addr_result = _call_official("address_name", {"address": name})
addr = None
if isinstance(addr_result, dict):
addr = addr_result.get("address", addr_result.get("name"))
elif isinstance(addr_result, str):
addr = addr_result
if not addr:
return json.dumps({"error": f"Could not resolve name: {name}"})
xrefs = _call_official("xrefs", {"address": addr})
if isinstance(xrefs, dict):
pass
elif isinstance(xrefs, list):
xrefs = {"xrefs": xrefs}
return json.dumps(xrefs, indent=2, default=str)
@mcp.tool(name="binary_overview")
async def binary_overview_tool() -> str:
segments = _call_official("list_segments", {}) or {}
if isinstance(segments, dict):
segments = segments.get("segments", segments)
documents = _call_official("list_documents", {}) or []
if isinstance(documents, dict):
documents = documents.get("documents", documents)
procedures = _call_official("list_procedures", {}) or {}
if isinstance(procedures, dict):
procedures = procedures.get("procedures", procedures)
proc_count = len(procedures) if isinstance(procedures, dict) else "unknown"
strings = _call_official("list_strings", {}) or {}
if isinstance(strings, dict):
strings = strings.get("strings", strings)
string_count = len(strings) if isinstance(strings, list) else "unknown"
doc_name = documents[0] if documents else "unknown"
result = {
"document": doc_name,
"segments": [{"name": s.get("name", ""), "start": s.get("start", ""), "end": s.get("end", "")} for s in segments[:10]],
"segment_count": len(segments) if isinstance(segments, list) else "unknown",
"procedure_count": proc_count,
"string_count": string_count,
}
return json.dumps(result, indent=2, default=str)
def main():
mcp.run()
if __name__ == "__main__":
main()
View File
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
Tests for HopperProxyMCP server with mocked subprocess.
"""
import asyncio
import json
import os
import sys
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
class MockHopperClient:
def __init__(self, server_path=None):
self._server_info = {"name": "HopperMCPServer", "version": "0.1.0"}
self._started = False
self._call_history = []
def start(self):
self._started = True
return self._server_info
def stop(self):
self._started = False
def call_tool(self, tool_name, arguments=None):
arguments = arguments or {}
self._call_history.append({"tool": tool_name, "args": arguments})
if tool_name == "list_documents":
return ["test_binary"]
elif tool_name == "list_segments":
return [{"name": "__TEXT", "start": "0x100000000", "end": "0x1000e0000"}]
elif tool_name == "list_procedures":
return {
"0x1040f4124": "-[_TtC6Signal29AccountSettingsViewController viewDidLoad]",
"0x1040f4150": "_TtC6Signal29AccountSettingsViewController"
}
elif tool_name == "procedure_pseudo_code":
return "func viewDidLoad() { self.setup() }"
elif tool_name == "search_procedures":
return {"0x1040f4124": "-[_TtC6Signal29AccountSettingsViewController viewDidLoad]"}
elif tool_name == "procedure_callees":
return ["0x1040f4160", "0x1040f4170"]
elif tool_name == "procedure_callers":
return ["0x1040f4100"]
elif tool_name == "list_names":
return [
{"address": "0x1000a0000", "name": "_OBJC_CLASS_$_SignalAppDelegate"},
{"address": "0x1000a0010", "name": "_TtC6Signal11AppDelegate"}
]
elif tool_name == "address_name":
return {"address": "0x1040f4124", "name": "viewDidLoad"}
elif tool_name == "xrefs":
return ["0x1040f4100", "0x1040f4110"]
elif tool_name == "list_strings":
return [{"address": "0x100020000", "value": "Hello World"}]
elif tool_name == "set_comment":
return "OK"
elif tool_name == "set_address_name":
return "OK"
else:
return {"result": "ok"}
@property
def server_info(self):
return self._server_info
@pytest.fixture
def mock_client():
return MockHopperClient()
@pytest.fixture
def server_module(mock_client):
with patch("HopperProxyMCP.hopper_client.HopperMCPSubprocessClient") as mock_class:
mock_instance = MagicMock()
mock_instance.start.return_value = {"name": "HopperMCPServer", "version": "0.1.0"}
mock_instance.stop.return_value = None
mock_instance.call_tool.side_effect = mock_client.call_tool
mock_class.return_value = mock_instance
from fastmcp import Client
import importlib
import HopperProxyMCP.server as server_mod
importlib.reload(server_mod)
yield server_mod
class TestProxyTools:
@pytest.mark.asyncio
async def test_binary_overview(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("binary_overview", {})
assert "test_binary" in result.data
assert "__TEXT" in result.data
@pytest.mark.asyncio
async def test_swift_classes(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("swift_classes", {"pattern": ""})
data = json.loads(result.data)
assert data["count"] >= 1
assert any("_TtC" in c["name"] for c in data["classes"])
@pytest.mark.asyncio
async def test_get_objc_classes(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("get_objc_classes", {"pattern": ""})
data = json.loads(result.data)
assert data["count"] >= 1
@pytest.mark.asyncio
async def test_get_objc_protocols(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("get_objc_protocols", {})
data = json.loads(result.data)
assert "count" in data
@pytest.mark.asyncio
async def test_batch_decompile(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("batch_decompile", {
"addresses": ["0x1040f4124", "0x1040f4150"]
})
data = json.loads(result.data)
assert "0x1040f4124" in data
@pytest.mark.asyncio
async def test_get_call_graph_forward(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("get_call_graph", {
"address": "0x1040f4124",
"direction": "forward",
"depth": 2
})
data = json.loads(result.data)
assert "0" in data or 0 in data
@pytest.mark.asyncio
async def test_get_call_graph_backward(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("get_call_graph", {
"address": "0x1040f4124",
"direction": "backward",
"depth": 2
})
data = json.loads(result.data)
assert "0" in data or 0 in data
@pytest.mark.asyncio
async def test_analyze_swift_types(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("analyze_swift_types", {})
data = json.loads(result.data)
assert "total" in data
assert "categories" in data
@pytest.mark.asyncio
async def test_find_xrefs_to_name(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("find_xrefs_to_name", {
"name": "viewDidLoad"
})
data = json.loads(result.data)
class TestOfficialToolProxying:
@pytest.mark.asyncio
async def test_list_documents(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("list_documents", {})
assert "test_binary" in result.data
@pytest.mark.asyncio
async def test_list_segments(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("list_segments", {})
assert "__TEXT" in result.data
@pytest.mark.asyncio
async def test_list_procedures(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("list_procedures", {})
assert "_TtC" in result.data
@pytest.mark.asyncio
async def test_procedure_pseudo_code(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("procedure_pseudo_code", {
"procedure": "0x1040f4124"
})
assert "viewDidLoad" in result.data
@pytest.mark.asyncio
async def test_search_procedures(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("search_procedures", {
"pattern": "viewDidLoad",
"case_sensitive": False
})
assert "viewDidLoad" in result.data
@pytest.mark.asyncio
async def test_procedure_callees(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("procedure_callees", {
"procedure": "0x1040f4124"
})
assert "0x1040f4160" in result.data or "0x1040f4170" in result.data
@pytest.mark.asyncio
async def test_procedure_callers(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("procedure_callers", {
"procedure": "0x1040f4124"
})
assert "0x1040f4100" in result.data
@pytest.mark.asyncio
async def test_list_names(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("list_names", {})
assert "_OBJC_CLASS_" in result.data or "_TtC" in result.data
@pytest.mark.asyncio
async def test_address_name(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("address_name", {
"address": "0x1040f4124"
})
assert "viewDidLoad" in result.data
@pytest.mark.asyncio
async def test_xrefs(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("xrefs", {
"address": "0x1040f4124"
})
assert "0x1040f4100" in result.data
@pytest.mark.asyncio
async def test_set_comment(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("set_comment", {
"address": "0x1040f4124",
"comment": "Test comment",
"document": "test_binary"
})
assert result.data in ["OK", '{"result": "ok"}']
@pytest.mark.asyncio
async def test_set_address_name(self, server_module, mock_client):
from fastmcp import Client
async with Client(server_module.mcp) as client:
result = await client.call_tool("set_address_name", {
"address": "0x1040f4124",
"name": "myFunction",
"document": "test_binary"
})
assert result.data in ["OK", '{"result": "ok"}']
class TestMockClient:
def test_call_history(self, mock_client):
mock_client.call_tool("list_documents")
mock_client.call_tool("list_segments")
assert len(mock_client._call_history) == 2
assert mock_client._call_history[0]["tool"] == "list_documents"
assert mock_client._call_history[1]["tool"] == "list_segments"
if __name__ == "__main__":
pytest.main([__file__, "-v"])