* A client of MCP 2026-07-28, such as FastMCP 4.1.4, begins with
`server/discover` and falls back to `initialize` on an error. rmcp 1.8
accepts nothing but `initialize` first: it logged "expect initialized
request, but received: ... server/discover" and exited, so the client
only saw the connection close.
* rmcp 3.8 answers `ping` and `server/discover` before `initialize`
(with the supported revisions, 2024-11-05 to 2026-07-28), serves the
requests of 2026-07-28 clients without `initialize`, and answers other
early requests with an error instead of exiting. FastMCP 4.1.4 now
connects and calls tools on both backends.
* Port to its API: `ContentBlock`, `Role`, `Resource`,
`ResourceTemplate`, and `ReadResourceResponse` for `read_resource`.
rmcp 3 needs Rust 1.88.
* tests/protocol.rs speaks raw JSON-RPC for both kinds of client; with
rmcp 1.8 both tests fail on the closed connection.
* Agents learn Rizin's commands from its errors and usage messages. The
`command` tool returned them only when Rizin reported a failure, so:
- through a pipe to Rizin 0.9.1, which reports no status, a wrong
command (`axt foo bar baz`, `s nosuchflag`, `wa "bogus"`) came back
as an empty success and its error only reached the server log;
- a command that succeeds and still says something lost it, e.g.
`e asm.bits=7`, which is OK on Rizin 0.9.1 and 0.10 but logs
"cannot set '7'", or the warnings of `ihj`.
* Return the messages after the output as their own text item, so the
output itself (JSON, say) stays as Rizin printed it.
* Without a status, take a line that printed nothing but an `ERROR`
message as failed, as the decompile tool already does.
* The tests analyze `/bin/ls` and rely on what GNU `ls` is: a C program
whose `main` calls many functions and references ASCII strings, with
about 1300 strings in the whole file. Since Ubuntu 25.10, and so on
the Ubuntu 26.04 that `ubuntu-latest` is moving to, `ls` is the
multicall binary of uutils (0.10.0 on 26.04): a Rust program of
11.8 MB whose `main` only dispatches, with 138456 strings in the whole
file, 18 MB of `izzj` that the paging test reads once per page.
* GNU coreutils stays installed there as the required package
gnu-coreutils, which names its programs with a `gnu` prefix, so take
`/usr/bin/gnuls` when it exists, in the tests and the benchmark.
* The contract test timed out on both CI channels after 120 s at
`/Rj "ret"`: `ubuntu-latest` is moving to Ubuntu 26.04, where `ls` is
the multicall binary of uutils, 11.8 MB with 4.8 MB of code instead of
the 82 KB of GNU `ls`, and the time of `/R` grows with the bytes
searched.
* Search the first 64 KiB of the largest executable section instead, in
the contract test and in the search_rop tests, so their time does not
depend on the target. That window of `/bin/ls` still has over 1000
gadgets, so the listing still exceeds a page.
* `gadget.cache` is on by default, and a search served from the cache
differs from a fresh one: the cache lists the gadgets by address while
a fresh search lists them per terminating instruction, so on `/bin/ls`
the order differs from the 58th gadget on, and the pages of a listing
were cut from two orders.
* `/R` keeps only the first gadget of identical bytes, and the cache
holds what the search that built it kept: after a search of the whole
file, a search of 0x10000-0x14d70 of `/bin/ls` returned 236 gadgets
instead of 284, on Rizin 0.9.1 and 0.10.
* Turn the cache off for each search: every call is then a fresh search
of its range, so its result only depends on the range and the query.
* `/R` lifts every candidate instruction to RzIL, so its time grows with
the bytes searched: `/Rj "ret"` takes about three minutes for the
7.4 MB of code of the uutils 0.0.24 multicall binary, which is `ls` on
Ubuntu 26.04, beyond the 120 s command timeout. A timeout kills a pipe
session and leaves an in-process one blocked until the search ends.
* `from`/`to` bound the search through `search.from`/`search.to`, which
both Rizin 0.9.1 and 0.10 apply to `/R`. They are resolved by the
server first: an unknown flag in a `search.*` value evaluates to 0 with
status OK, and `search.to=0` only warns and then searches everything
from `search.from` on, so either would silently widen the search.
* The description says when to restrict the search.
* Since rizin@5b34e04, `pdj` no longer fails when it cannot read all of
its window, so that what it could read is printed: at 0xdeadbeef of
`/bin/ls` it now lists 0xff `invalid` instructions with status OK and
no message, and the tool returned 32 of them as if they were code.
* When the first instruction is `invalid`, ask `aij`, which has no
`read` where nothing readable is mapped (on 0.9.1 and 0.10 alike), and
refuse the address then. 0xff bytes that are mapped are still listed.
* The contract test pins the `read` field of `aij`.
* The README states the tool counts (85 in all, 62 by default), that it
was tested against Rizin `dev` (0.10.0) and v0.9.1, and the cost of the
status round trip of the pipe backend.
* Until Rizin publishes versioned JSON schemas (rizin#6543 B2, F1), a
renamed command or field silently turns into an empty or wrong tool
result. Pin what the server relies on instead, so the nightly CI run
against Rizin `dev` fails loudly.
* Every command the tools, resources and backends run must exist: its `?`
help succeeds exactly when it does (an unknown command or subcommand is
INVALID). `_s` and `Flj`, which the server uses only when present, are
required only from Rizin 0.10 on; plugin commands are exempt.
* The command names in the string literals passed to the command helpers
in `src/server.rs` must all be in that list, so a new command cannot be
added without being checked.
* The JSON fields read by function_summary, the annotation tools,
search_rop, list_flags, the FLIRT tools, decompilers and
disassemble_function must be present with the expected types.
* A source install of Rizin has an empty sigdb (`install_sigdb` defaults
to false) and no decompiler plugins, and `flirt_list`, `flirt_apply_sigdb`
and `decompilers` only reported emptiness, without saying why.
* `flirt_list` returns the sigdb as a paged JSON listing (`Flj`) and, when
it is empty, a `hint`: build with `-Dinstall_sigdb=true`, or clone
rizinorg/sigdb and pass `--sigdb <dir>`, which the server now sets as
`flirt.sigdb.path` on every open. `flirt_apply_sigdb` gives the same
hint when nothing matched because the sigdb is empty, and lists the
applied files without the " signature file" suffix.
* `decompilers` adds the plugin directory and how to install rz-ghidra and
jsdec when one is missing.
* The README gets a table of the optional components; CI clones the sigdb
to test `--sigdb`.
* `Fl` has a JSON mode only since Rizin 0.10; before it, `flirt_list`
returns the `Fl` table.
* Without annotations, MCP clients must assume every tool may destroy
data and reach the outside world, so they cannot surface the safe
queries as such.
* Mark the 55 queries `readOnlyHint`; for the others set
`destructiveHint` (session replacement, overwritten names and comments,
written bytes and files, raw commands) and `idempotentHint`.
`openWorldHint` is set for the raw commands, the debugger (it runs the
target), and the Silhouette tools (a remote server).
* A test requires the hints on every tool and keeps them consistent with
the capability gates.
* A client could only open files on the server's filesystem, so a sample
delivered in the chat had to be staged first.
* `open_data` takes base64 (or hex) data, up to 64 MiB, writes it to a new
0600 file in a per-process temporary directory under a sanitized name,
and opens it like `open_file`. The file belongs to the session and is
deleted when it ends (close, reopen, or death), after the backend has
released it.
* Understanding a function took one round trip per aspect (info,
signature, callers, callees, strings, decompilation). This is
rizin#6543 D2, implemented here first.
* `function_summary` returns the name, address, size, blocks, complexity,
argument and local counts, and signature from `afij`; callers from
`axtj`; distinct callees from the CALL references; the referenced
strings; and the first lines of the decompilation with the best engine
available. `max_items` (default 20) and `snippet_lines` (default 30) cap
it, with totals for what was left out.
* Strings come from `izj`, by containment: a reference often points into
a string whose tail another string shares, and `ps` there guessed
UTF-16 for ASCII text.
* The decompile engine selection moved into a helper shared with
`decompile`; the analyze_function prompt now starts with the summary.
* Everything an agent renamed, commented or patched was lost with
`close_file`.
* Record every state-changing command the tools run (the annotation
commands and verified writes, at resolved addresses) in a journal kept
until the next open, also past `close_file`. `export_annotations`
returns it as a Rizin script to replay with `. <script>`.
* `save_project` saves the session as a Rizin project with `Ps`; it writes
a file, so it is offered only with `--allow-write`, and it checks that
the file was written.
* A test replays an exported script on a fresh analysis and reopens a
saved project with `rizin -p`.
* Without them, an agent could not record what it understood (the
hypothesis -> name -> re-read loop of RE), short of raw commands, and
its findings did not show up in later disassembly or decompilation.
* Add `rename_function` (afn), `rename_variable` (afvn), `set_comment`
(CC, replacing unless `append`) and `remove_comment` (CC-), `set_flag`
(f+, since `f` refuses an address that already has a flag) and
`remove_flag` (f-), and `set_function_signature` (afs). Names, comments
and prototypes are passed with `quote_arg()`; flag names must be
`[A-Za-z0-9_.:]`. Each tool resolves the location to an address first
and returns the state Rizin stored.
* `f`, `afvn` and others report success while only logging an error
("cannot find var by name"), so these tools also fail on a logged error.
* They change the analysis, not the file, so a read-only file is fine,
but as the QA plan asks they are offered only with `--allow-write`.
* A location that is neither an address nor a flag is rejected also where
the pipe cannot report the status of `s @ <loc>` (before Rizin 0.10).
* Found while fixing the QA items: `list_flags` with a space ran
`fs <space>;flj`, which selects that space for the rest of the session,
so every later `list_flags` without a space (and any raw `fl`) only
listed it: on /bin/ls, 605 string flags instead of 997 flags.
* Select the space for this listing only, with `fss+ <space>;flj;fss-`.
`fss+` creates a space that does not exist, so an unknown space is
answered from `fslj` with an empty listing.
* The `pseudo` engine's text differed between backends (2652 vs 3112
bytes in the QA run) with identical eval settings. The cause is in
Rizin: since rizin#6062, `ds_init()` suppresses the jump arrows
(`asm.lines`) whenever `core->is_pipe` is set, and `rz_core_cmd_str()`
sets it for every captured command, so in-process `pd*` text never has
them. Clearing `is_pipe` from the outside is not possible, and would
let `!` commands write to the MCP stdout.
* Render the pseudo code without jump arrows (`asm.lines=false`), which
makes both backends print the same text, and with `pdr` instead of
`pdf`: on /bin/ls, `pdf @ main` takes ~30 s (in Rizin 0.9.1 and dev
alike) because every pointer operand classifies a buffer as large as
the whole function, while `pdr` prints the same blocks in 0.4 s.
* A test decompiles `main`, `entry0` and the three largest functions on
both backends and requires identical text within 10 s each.
* `disassemble` at an unmapped address said "no output for a command
expecting JSON"; with command status it now fails with Rizin's "Failed
to read chunk ... at 0xdeadbeef" (covered by a new test).
* `il_function`: `plf` fails without a message where there is no
function, so say so when `afij` finds none; empty output of the text
command is the new `EmptyOutput`, not `EmptyJson`.
* `flirt_apply` reported "Found 0 FLIRT signatures" for a missing file,
since `Fs` returns OK and only logs "Can't open": check that a plain
path exists, fail on logged errors, and return the number of matched
functions.
* `flirt_apply_sigdb` checked its filter, a substring of the sigdb paths,
with the path validator, rejecting `@` and other characters; quote it
instead. It now returns the number of functions named (counted in the
`flirt` flag space) and the signature files applied.
* `decompile` moved to the next engine on any error and ended with
"install the plugin" even after a timeout, retrying a wedged session
twice. Only a missing plugin (NONEXISTINGCMD) or an empty result moves
on now; any other failure is returned as is. The skipped engines and why
are in the structured result.
* A pipe to Rizin before 0.10 sees a failure only as no output plus an
error message: `il_function` checks for a function on empty output as
well, and `decompile` treats an error message without output as a
failure of that engine.
* After a command timeout killed the session, `session_info` still said
`open: true`, because `state()` did not consult the backend; only the
next command cleared it. A pipe whose rizin exited (e.g. after a raw
`q`) was not even marked dead, so every later command failed with
"worker is gone".
* `state()` drops a session whose backend is dead, and the session
manager clears one that dies during a command, keeping the reason (e.g.
"rizin command timed out after 120s: aaaa") in `ended` until another
file is opened. A transport failure now marks the backend dead.
* Not downgraded: the QA run saw the in-process backend "recover" after a
`!sleep` wedge only because `sleep` returned. A new test shows that,
until the timed-out call returns, it holds the global Rizin lock and
opening a new in-process session fails at the startup timeout; the
rzcore and README notes now say so.
* With `--no-raw`, `command` and `command_json` were still listed and only
failed when called, so clients offered them and agents burned turns on
them. The write and debugger tools had the same problem without
`--allow-write` / `--allow-debug`.
* Build the server's tool router per configuration and remove the tools
of each disabled capability (`RAW_TOOLS`, `WRITE_TOOLS` including
`flirt_create`, `DEBUG_TOOLS`); a call to one is refused as an unknown
tool, and the existing guards stay as a second line.
* A test keeps the lists in sync with every `write_*` and `debug_*` tool.
* Listings had no limit: `list_strings` returned ~313 KB of JSON on
/bin/ls and `search_rop ret` ~1 MB, and the text content repeated the
structured content pretty-printed. Tens of MB on a real sample blow the
client's context, and an agent could not ask for the first 50 items.
* Listing tools (strings, functions, symbols, imports, exports, sections,
segments, relocations, libraries, classes, flags, flag spaces, xrefs,
every search, both diffs, hash algorithms, resources, and the debugger
listings) take `offset`/`limit` (default 100 items, `--default-limit`)
and return `items`, `total`, `offset`, `returned`, `truncated`, and
`next_offset` while more remain. `disassemble_function` pages the
function's instructions the same way, in `ops_page`.
* No result exceeds `--max-output-bytes` (128 KiB by default): a page is
cut to the items that fit, text (raw `command`, decompiled code) is cut
at a line end with a notice, and an object still over the ceiling is an
error result asking for a narrower request rather than silently cut
JSON.
* The text content is the structured content serialized compactly. On
/bin/ls the first page of `list_strings` is now 10.7 KB and of
`search_rop ret` 20 KB.
* Rizin still builds the whole listing (no native pagination yet,
rizin#6543 D1); the slicing happens in the server.
* Found while testing the QA fixes with the official SDKs: MCP defines
`structuredContent` as an object, and every tool that returned a JSON
array there (list_functions, sections, imports, strings, searches, ...)
made the TypeScript SDK (1.32.1) throw a ZodError ("expected record,
received array") and the Python SDK (2.3.0) fail validation, so those
tools were unusable from such clients.
* Wrap arrays as `{"items": [...]}` and other non-objects (the `null` of
absent metadata) as `{"result": ...}`; the text content is the same
object, serialized.
* An MCP-level test calls the listing tools on both backends and checks
that each structured result is an object.
* The in-process backend passed `-` to `rz_core_file_open_load()`, which
looked for a file named `-` and failed with "file not found: -". The
pipe backend ran `rizin -q0 -`, which reads a file from stdin, i.e.
from the command pipe, so it never became ready and the open failed at
the startup timeout.
* Treat `-` as "no target": create the core without opening a file, or
run `rizin -q0` without a target.
* When Rizin cannot open a target, both backends now return
`OpenFailed` ("rizin cannot open '<target>'" and Rizin's reason, logged
in process or read from stderr before rizin exits), instead of "file
not found" in process and a handshake error through the pipe.
* Refuse directories up front: the CLI does, but the library opens one as
an endless file.
* `wa mov eax, 1 @ 0x20` assembles each space-separated word as its own
instruction, so it failed on `mov`, and the tool reported "Assembled and
wrote" because the output was empty.
* Split the assembly at `;` and newlines, assemble it with
`pa "<insns>" @ <addr>` (so relative branches are encoded for the
destination), and write the bytes with `wx`. The result now carries the
bytes written.
* `wx` returns OK even when it cannot write (e.g. at an unmapped address),
saying so only in an error message, so write_hex and write_string
"succeeded" as well. All three tools now read the bytes back and fail
with the new `WriteFailed` error (code -32001) otherwise.
* A pipe to Rizin before 0.10 sees a failed `pa` only as no output; its
"Cannot assemble" message is then reported as the failure.
* `/Rj pop rdi; ret` was split by Rizin's shell at `;`: the in-process
backend failed the whole line, the pipe backend silently returned the
gadgets of `/Rj pop rdi` alone. Quoted, `/Rj "pop rdi; ret"` returned
nothing, because `/R` does not trim its `;`-separated fields and looked
for " ret".
* Split the query on `;`, trim the instructions and collapse their spaces,
and pass them as one double-quoted filter, `/Rj "pop rdi;ret"`.
`quote_arg()` escapes the four characters that stay special inside
double quotes (`\`, `"`, `$`, backtick), so any other text reaches the
command verbatim; a test echoes such text through both backends.
* Describe what `/R` matches: the instructions in order, each as a
substring, possibly with others in between. `exact: true` keeps only the
gadgets made of exactly those instructions.
* Add MCP-level tests that drive the server through an rmcp client over an
in-memory stream, on both backends, holding the same lock per in-process
server as the integration tests.
* Build Rizin `dev` and v0.9.1 into `~/.local/rizin` (cached by commit),
the documented install path whose multiarch layout and missing rpath
broke the build and the start of the binary, then lint, build, start
the binary without LD_LIBRARY_PATH, and run the tests against each.
* Run nightly as well, so command or API drift on Rizin's `dev` branch
shows up in CI rather than in users' sessions.
* A pipe-only job checks formatting and the build without librz.
* The integration tests hold a lock while an in-process session is open:
Rizin 0.9 keeps its console in globals, so the cores of tests running in
parallel must not overlap (opening a file then fails at random).
* A failed command was indistinguishable from an empty success: in
process, `rz_core_cmd_str()` returns NULL on failure and the FFI mapped
it to ""; through the pipe, the error went to rizin's stderr. `command
pdg` without rz-ghidra returned an empty success, `/Rj pop rdi; ret`
failed with a misleading "no output for a command expecting JSON", and
`write_assembly` reported success after `wa` failed.
* In process, run commands with `rz_core_cmd_str_status()` (Rizin 0.10,
detected in the installed `rz_core.h`), which keeps the output of a
failed line and returns its status. Rizin's log messages, which explain
failures, are routed to the server through a small C shim, since stable
Rust cannot define the variadic `RzLogCallback`, and collected per
command. Before 0.10, `rz_core_cmd_str()` returning NULL is reported as
`FAILED`.
* Through the pipe, query `_sj` after every command when the startup probe
finds it (Rizin 0.10; about 50 µs more per command on `/bin/ls`), and on
Unix read rizin's stderr alongside stdout with poll(), so the messages a
command printed before its NUL are returned with it. Without `_sj` the
status is `UNKNOWN` and commands are judged by their output, as before.
* A failure becomes `RizinError::CommandFailed`, a JSON-RPC error with the
new code -32001 and `command`/`status`/`detail` in `data`; `EmptyJson`
now only means a successful command printed nothing, and carries
Rizin's messages. The raw `command` tool returns a tool error result
that keeps the output printed before the failure.
* Optional metadata (`iCj` fails on an unsigned ELF) still maps a runtime
failure to "absent", but not an unknown command or wrong arguments; a
failed analysis at open time is reported in `analysis_error` instead of
being ignored.
* Before Rizin 0.10, a missing command is still recognized from its
"Command '...' does not exist." message, so a plugin command that is not
installed reports NONEXISTINGCMD on both backends; the suite passes
against v0.9.1 as well.
* With Rizin in a custom prefix the binary failed to start with
`librz_core.so.0.10: cannot open shared object file`, and an MCP client
launching a bare command cannot set LD_LIBRARY_PATH. `cargo test` had
the same problem.
* Add `-Wl,-rpath,<libdir>` for dynamic linking, except for the loader's
default directories; `RIZIN_RPATH=0` opts out for packagers.
* `pkg-config --define-prefix` rebuilds the prefix assuming a
`<prefix>/lib/pkgconfig` layout, so for the multiarch
`lib/x86_64-linux-gnu/pkgconfig` layout Meson uses even for a custom
prefix it reports `<prefix>/lib/lib/x86_64-linux-gnu`. That query
succeeded, so the correct plain `libdir` fallback never ran and the
link failed with `unable to find library -lrz_core`.
* Query the plain `libdir` first and keep `--define-prefix` only for
relocated installs; trust a directory only if it holds `librz_core`.
* Fail the build when the directory lacks `rz_core` or `rz_util`, instead
of linking `rz_core` alone, which left `rz_mem_free` (in `rz_util`)
undefined.
* Rerun the build script when `librz_core` or `rz_core.pc` change, and on
the other pkg-config environment variables.