Write a client or MCP server
A checklist for tools that drive the daemon. One connection, a label per agent, adopting projects by hash, handling refusals, the wire, and pinning a release.
On this page
This page is for whoever writes the thing agents talk to: an MCP server, a harness plugin, a pipeline. The daemon is a generic Ghidra front end; the tools you expose, and what they check, belong to your client.
One connection per process
import ghidra_session as gs
s = gs.connect(client="my-mcp", harness="claude-code") # joins the user's daemon, or starts itThe connection is thread-safe. Without parallel, one connection's requests run in the order sent, so a server that handles several tool calls at once sends parallel=True on each independent one. Name yourself: status, the log and every holder field use these names.
One label per agent
If one connection serves several agents, give each its own label, so a busy answer says which agent holds the lock and cancel stops only that agent's work: on main · 12.3-hydra.2
agent = s.labelled(client="agent-7", harness="claude-code") # call, send, cancel, cancel_allAdopt a project, do not import
Agents usually arrive with a binary that someone already analysed. Open the existing project read-only (no copy, no lock, nothing written), find the program by hash, and check the hash before trusting answers:
s.call("open_project", path=r"C:\re\Demo.gpr")
hit = s.call("list", sha256=digest)["entries"][0]
info = s.call("open_program", path=hit["key"])
assert info["sha256"] == digestOnly import when the binary is new, into a writable working project, with "analyze": true. The same file is never imported twice. For changes the master project must not see, fork it (Several writers).
Handle refusals by code
Every refusal is {"ok": false, "error": {"code", "message", "details"}}. The details of busy, conflict, not_owner and read_only name the holder and carry a suggest:
| Code | It means | Do this |
|---|---|---|
busy | a slot or lock did not come free within wait (10 s) | retry, or send a longer wait |
not_owner | another client owns the program writable | send overlaySuggestion (private annotations), suggestion (a fork), or join with share when suggest says so |
conflict, reason: "changed" | the value changed since you read it (expect) | read it again and decide |
conflict, other reasons | the project lock is another process's, the target exists, a copy is too large | change the request: retrying will not help |
unsaved_changes | closing would drop changes | save, or close with "discard": true |
All codes are in the reference.
Long work
send returns a request id at once; wait collects the answer. Long commands emit progress events in between, and cancel takes the id:
rid = s.send("analyze", parallel=True)
# ... s.call("status") shows it under clients[].requests, with its progress
s.call("cancel", target=rid) # or agent.cancel(rid) on a labelled viewAny language: the wire
Run python ghidra_session.py ensure once, then read default.json from the state directory for host, port and token. Connect, and speak one JSON object per line. The first request must be hello with the token. A real exchange, abridged:
> {"id": 1, "cmd": "hello", "client": "my-tool", "harness": "none", "clientVersion": "1.0.0", "token": "<token>"}
< {"id": 1, "ok": true, "result": {"protocol": 2, "minClient": "1.0.0", "hydraVersion": "12.3-hydra.dev", "ghidraVersion": "12.3", "installId": "c5e9d279befb866f", ...}, "ms": 0}
> {"id": 2, "cmd": "open_project", "path": "C:\\re\\Demo.gpr"}
< {"id": 2, "ok": true, "result": {"id": "p1", "name": "Demo", "writable": false, "revision": "0087e270279abab7", ...}, "ms": 0}
> {"id": 3, "cmd": "decompile", "program": "p1:/choice.exe", "address": "0x140001010", "maxChars": 120}
< {"id": 3, "ok": true, "result": {"name": "FUN_140001010", "signature": "undefined8 FUN_140001010(void);", "truncated": true, "c": "..."}, "ms": 301}The reference has the full request and response shape.
Pin a release
Do not copy ghidra_session.py from main and hope the protocol still fits. on main · 12.3-hydra.2 Each release ships the client as hydra-client-<version>.zip next to the engine, with both in SHA256SUMS. Keep the engine and client of one release together.
python ghidra_session.py version prints the client's side:
{"client": "1.0.0", "protocol": 2, "release": "dev"}hello, status and the endpoint file give the engine's: protocol, minClient, hydraVersion, ghidraVersion and installId. The Python client refuses a daemon whose protocol differs or whose minClient is newer than itself, with IncompatibleError (exit code 4 on the command line); status and stop still work, so you can stop it. A client in another language should apply the same two checks.
The rules behind the numbers: protocol changes only when a request or answer changes shape incompatibly. The client follows major.minor.patch. minClient rises only when an older client would misbehave. New fields and new optional request fields are additions and change none of them.
To upgrade: read the release's changelog section, stop the old daemon, put the new engine and client side by side, point your tool at them, then run version and ensure and check hello.
Checked against the engine on 2026-10-26. Every detail is in the README on GitHub.