Forward ghidra()/ghidra_tools() to the shared read-only Ghidra MCP server

Every agent reaches GhidrAssistMCP through Harbor's own MCP server, so no
per-host configuration is needed. Configured by ghidra_url in [reverse];
long decompiles are polled; also 'harbor re ghidra <tool> <json>'.
This commit is contained in:
KeelrLeek committed 2026-10-05 12:27:19 -05:00
1 parent 3f6df88f95
commit 7ed914ebdb
4 files changed
+186 -6

No files matched your search

+12 -5
View File
@@ -255,8 +255,9 @@ def build_parser():
p = sub.add_parser("who", help="who has worked on a scope (notes, reviews, claims, messages)")
p.add_argument("scope")
p = sub.add_parser("re", help="reverse-engineering lookups from the project's image and annotations")
p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup"])
p.add_argument("target", help="an address (0x4AD850) or a name")
p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup", "ghidra", "ghidra-tools"])
p.add_argument("target", nargs="?", default="", help="an address (0x4AD850) or a name; for ghidra, the tool")
p.add_argument("arguments", nargs="?", default="{}", help="ghidra: the tool's arguments as JSON")
p.add_argument("--max-bytes", type=int, dest="max_bytes")
p.add_argument("--count", type=int, default=16)
p.add_argument("--as", dest="as_type", default="hex", choices=["hex", "int32", "uint32", "float", "double", "string"])
@@ -673,9 +674,15 @@ def main(argv=None):
r = harbor.summarize(who(), args.thread, text)
out(r, f"summary #{r['id']} pinned to {r['thread']}")
elif cmd == "re":
from .reverse import Reverse
r = Reverse(harbor.project.reverse)
if args.action == "disasm":
from .reverse import Ghidra, Reverse
r = None if args.action.startswith("ghidra") else Reverse(harbor.project.reverse)
if args.action == "ghidra-tools":
res = Ghidra(harbor.project.reverse).tools()
out(res, "\n".join(f"{t['name']:<26} {t['description'].splitlines()[0][:90]}" for t in res))
elif args.action == "ghidra":
text = Ghidra(harbor.project.reverse).call(args.target, json.loads(args.arguments))
out({"result": text}, text)
elif args.action == "disasm":
text = r.disassemble(args.target, args.max_bytes)
out({"disassembly": text}, text)
elif args.action == "xrefs":
+34 -1
View File
@@ -35,7 +35,9 @@ review queue, presence and stop flags. Protocol:
9. A correction is send(..., corrects=<id of your earlier message>).
10. Before your session ends or your allowance runs out, write handoff(): finished, in flight, next.
11. To read the original machine code use re_disassemble, re_xrefs, re_bytes and re_lookup: they read the project's
own image with the team's current names, so do not write your own disassembler or offset maths."""
own image with the team's current names, so do not write your own disassembler or offset maths.
For Ghidra's view (decompiler, p-code, stack frames, struct field references, types) use ghidra_tools() once,
then ghidra(tool, arguments): one shared read-only Ghidra session holding the team's annotations."""
KindName = Literal["note", "question", "answer", "request", "finding", "retraction", "decision", "status", "handoff"]
Importance = Literal["normal", "high", "urgent"]
@@ -484,6 +486,37 @@ def re_lookup(target: str) -> dict:
return _re(lambda r: r.lookup(target))
_ghidra = {}
def _gh(fn):
try:
h = harbor()
g = _ghidra.get(h.project.name)
if g is None:
from .reverse import Ghidra
g = _ghidra[h.project.name] = Ghidra(h.project.reverse)
return fn(g)
except (HarborError, projects.ProjectError) as error:
raise ToolError(str(error)) from None
@mcp.tool()
def ghidra_tools() -> list:
"""The tools of the team's shared Ghidra session (GhidrAssistMCP, read-only, the curated project with every
applied annotation): name, description and arguments. Call them with ghidra(tool, arguments)."""
return _gh(lambda g: g.tools())
@mcp.tool()
def ghidra(tool: str, arguments: dict | None = None) -> str:
"""Run one tool in the shared read-only Ghidra session, e.g. ghidra('get_code', {'function': 'GetFogLevel',
'format': 'decompiler'}), ghidra('get_function_stack_layout', {'function_name_or_address': '0x4ADC70'}),
ghidra('struct', {'action': 'field_xrefs', ...}). Long decompiles are waited for. Renames, retypes and other
changes are refused: names and types change through annotations/ and land on main."""
return _gh(lambda g: g.call(tool, arguments or {}))
def _report_in():
"""Tell Harbor this agent's app has its tools running: at start, then every 5 minutes while the app keeps the
server open (updates the connection only, not the agent's last-seen time)."""
+80
View File
@@ -276,3 +276,83 @@ class Reverse:
out["file"] = {"path": str(path.relative_to(self.src.parent)).replace(os.sep, "/"), "status": status}
break
return out
class Ghidra:
"""A small client for the shared, read-only Ghidra MCP server (GhidrAssistMCP over streamable HTTP), so every
agent reaches Ghidra through Harbor's own tools. Configured by `ghidra_url` in the [reverse] table."""
def __init__(self, config):
self.url = (config or {}).get("ghidra_url")
if not self.url:
raise HarborError("this project has no ghidra_url in its [reverse] table")
self.session = None
self.counter = 0
def _post(self, method, params=None, notify=False, timeout=120):
import urllib.error
import urllib.request
body = {"jsonrpc": "2.0", "method": method, **({"params": params} if params is not None else {})}
if not notify:
self.counter += 1
body["id"] = self.counter
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
if self.session:
headers["Mcp-Session-Id"] = self.session
request = urllib.request.Request(self.url, json.dumps(body).encode(), headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
self.session = response.headers.get("Mcp-Session-Id") or self.session
text = response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as error:
if error.code in (400, 404) and self.session and method != "initialize":
self.session = None # the server restarted: start a new session once
self._initialize()
return self._post(method, params, notify, timeout)
raise HarborError(f"the Ghidra server answered {error.code}: {error.read()[:300]!r}") from None
except OSError as error:
raise HarborError(f"the shared Ghidra server at {self.url} is not answering ({error}); start it with "
"R:/Workspace/Tools/re/ghidra-serve.ps1, or ask the coordinator") from None
if notify or not text.strip():
return None
if not text.lstrip().startswith("{"):
text = next((line[5:] for line in text.splitlines() if line.startswith("data:")), "{}")
answer = json.loads(text)
if "error" in answer:
raise HarborError(f"Ghidra: {answer['error'].get('message', answer['error'])}")
return answer.get("result")
def _initialize(self):
self._post("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "harbor", "version": "2"}})
self._post("notifications/initialized", notify=True)
def _ensure(self):
if not self.session:
self._initialize()
def tools(self):
"""The server's tools: name, description and argument schema."""
self._ensure()
return [{"name": t["name"], "description": t.get("description", ""),
"arguments": (t.get("inputSchema") or {}).get("properties", {}),
"required": (t.get("inputSchema") or {}).get("required", [])}
for t in self._post("tools/list")["tools"]]
def call(self, tool, arguments=None, wait=180):
"""Call one tool; long-running ones (the decompiler) are polled until they finish or `wait` seconds pass."""
import time
self._ensure()
result = self._post("tools/call", {"name": tool, "arguments": arguments or {}}, timeout=wait + 30)
text = "\n".join(c.get("text", "") for c in result.get("content", []) if isinstance(c, dict))
task = re.search(r"Task ID: ([0-9a-f-]{36})", text) if "async execution" in text else None
deadline = time.monotonic() + wait
while task and time.monotonic() < deadline:
time.sleep(1)
result = self._post("tools/call", {"name": "get_task_status", "arguments": {"task_id": task.group(1)}})
text = "\n".join(c.get("text", "") for c in result.get("content", []) if isinstance(c, dict))
if not re.search(r"Status: (RUNNING|PENDING|QUEUED)", text):
break
if result.get("isError"):
raise HarborError(text.strip() or f"Ghidra refused {tool}")
return re.sub(r"^\[Context\] Operating on: [^\n]*\n+", "", text)