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

+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)