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:
1 parent
3f6df88f95
commit
7ed914ebdb
4 files changed
+186
-6
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user