mirror of
https://github.com/rizinorg/rizin-mcp.git
synced 2026-11-07 06:46:00 -10:00
Add function_summary, an aggregate view of a function
* 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.
This commit is contained in:
1 parent
bb18b601ba
commit
5c9a7c5516
4 files changed
+298
-75
No files matched your search
@@ -85,6 +85,8 @@ produces the full listing, which the server then slices.
|
||||
|
||||
**Analysis & functions**
|
||||
- `list_functions`, `function_info`, `function_variables`, `analyze_function`
|
||||
- `function_summary` — one call for a function's name, size, signature,
|
||||
callers, callees, referenced strings, and the start of its decompilation
|
||||
- `xrefs_to`, `xrefs_from`, `function_graph`
|
||||
|
||||
**Disassembly & decompilation**
|
||||
|
||||
@@ -126,6 +126,20 @@ pub struct FunctionPageParams {
|
||||
pub page: PageParams,
|
||||
}
|
||||
|
||||
/// Parameters for `function_summary`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct FunctionSummaryParams {
|
||||
/// A location inside the function (hex address or flag/symbol name).
|
||||
pub at: String,
|
||||
/// Maximum number of callers, callees and strings listed (default 20,
|
||||
/// at most 100).
|
||||
#[serde(default)]
|
||||
pub max_items: Option<u64>,
|
||||
/// Number of decompiled lines included (default 30, at most 500).
|
||||
#[serde(default)]
|
||||
pub snippet_lines: Option<u64>,
|
||||
}
|
||||
|
||||
/// Parameters for `analyze`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct AnalyzeParams {
|
||||
|
||||
+215
-75
@@ -47,7 +47,8 @@ const RESOURCES: &[(&str, &str, &str)] = &[
|
||||
const INSTRUCTIONS: &str = "\
|
||||
Drive the Rizin reverse-engineering framework. Start by calling `open_file` with \
|
||||
a path (analysis defaults to `basic`/`aa`; use `analyze` for deeper passes). Then \
|
||||
explore with `list_functions`, `disassemble_function`, `decompile`, `binary_info`, \
|
||||
explore with `list_functions`, `function_summary` (a function at a glance), \
|
||||
`disassemble_function`, `decompile`, `binary_info`, \
|
||||
`headers`, `list_strings`, `imports`/`exports`/`symbols`, `xrefs_to`/`xrefs_from`, \
|
||||
and the search tools (`search_string`, `search_bytes`, `search_value`, `search_rop`, \
|
||||
`search_magic`, `search_crypto`). Inspect function signatures with \
|
||||
@@ -313,6 +314,94 @@ impl RizinServer {
|
||||
Ok((start, func))
|
||||
}
|
||||
|
||||
/// Decompile the function at `suffix` (an `at_suffix`) with the engine
|
||||
/// selection `which`, returning the engine used, its text, and the
|
||||
/// engines skipped and why.
|
||||
async fn decompile_text(
|
||||
&self,
|
||||
suffix: &str,
|
||||
which: &str,
|
||||
) -> Result<(&'static str, String, serde_json::Map<String, Value>), ErrorData> {
|
||||
// (engine name, command) candidates for each selection.
|
||||
let ghidra = ("ghidra", format!("pdg{suffix}"));
|
||||
let jsdec = ("jsdec", format!("pdd{suffix}"));
|
||||
// Pseudo-disassembly of the whole function, with temporary evals so the
|
||||
// global settings are left untouched. Without the jump arrows
|
||||
// (`asm.lines`), which Rizin omits from captured in-process output, the
|
||||
// text is the same on both backends. `pdr` walks the basic blocks:
|
||||
// `pdf` spends seconds per pointer operand in a large function.
|
||||
let pseudo = (
|
||||
"pseudo",
|
||||
format!("pdr @e:asm.pseudo=true @e:asm.lines=false{suffix}"),
|
||||
);
|
||||
|
||||
let candidates: Vec<(&'static str, String)> = match which {
|
||||
"auto" => vec![ghidra, jsdec, pseudo],
|
||||
"ghidra" => vec![ghidra],
|
||||
"jsdec" => vec![jsdec],
|
||||
"pseudo" => vec![pseudo],
|
||||
other => {
|
||||
return Err(RizinError::InvalidArgument {
|
||||
arg: other.to_string(),
|
||||
reason: "decompiler must be auto, ghidra, jsdec, or pseudo".into(),
|
||||
}
|
||||
.to_error_data());
|
||||
}
|
||||
};
|
||||
|
||||
// Only a missing plugin (or an empty result) moves on to the next
|
||||
// engine: an engine that ran and failed (e.g. no function there) would
|
||||
// fail the same way in the next one, and a timeout or dead session
|
||||
// must not be retried.
|
||||
let mut skipped = serde_json::Map::new();
|
||||
for (engine, cmd) in candidates {
|
||||
let out = self.manager.exec(&cmd).await.mcp()?;
|
||||
match crate::backend::check(&cmd, out) {
|
||||
Ok(out) if !out.output.trim().is_empty() => {
|
||||
return Ok((engine, out.output, skipped));
|
||||
}
|
||||
// A pipe to Rizin before 0.10 sees a failure only as no output
|
||||
// and an error message.
|
||||
Ok(out) if out.messages.iter().any(|m| m.starts_with("ERROR")) => {
|
||||
return Err(RizinError::CommandFailed {
|
||||
cmd,
|
||||
status: out.status,
|
||||
detail: out.detail(),
|
||||
}
|
||||
.to_error_data());
|
||||
}
|
||||
Ok(_) => {
|
||||
skipped.insert(engine.to_string(), json!("no output"));
|
||||
}
|
||||
Err(RizinError::CommandFailed {
|
||||
status: CmdStatus::NonExistingCmd,
|
||||
..
|
||||
}) => {
|
||||
skipped.insert(engine.to_string(), json!("not installed"));
|
||||
}
|
||||
Err(e) => return Err(e.to_error_data()),
|
||||
}
|
||||
}
|
||||
|
||||
let missing = skipped.values().any(|v| v == "not installed");
|
||||
let hint = if missing {
|
||||
"no decompiler output: the requested engine is not installed. Install the \
|
||||
rz-ghidra (pdg) or jsdec (pdd) plugin, or use decompiler=\"pseudo\"."
|
||||
} else {
|
||||
"no decompiler output for the function at this location."
|
||||
};
|
||||
Err(RizinError::NotPermitted(hint.into()).to_error_data())
|
||||
}
|
||||
|
||||
/// The flag name at `addr` (`fd`: the nearest flag and an offset).
|
||||
async fn name_at(&self, addr: u64) -> String {
|
||||
self.manager
|
||||
.cmd(&format!("fd @ 0x{addr:x}"))
|
||||
.await
|
||||
.map(|n| n.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Guard used by the raw command tools.
|
||||
fn ensure_raw(&self) -> Result<(), ErrorData> {
|
||||
if !self.manager.config().allow_raw {
|
||||
@@ -719,6 +808,127 @@ impl RizinServer {
|
||||
Ok(self.listing(self.manager.cmdj("aflj").await.mcp()?, &p))
|
||||
}
|
||||
|
||||
/// Aggregate view of a function in one call.
|
||||
#[tool(
|
||||
description = "Summarize the function containing a location in one call: its name, \
|
||||
address, size, blocks, signature, callers, callees, the strings it references, and \
|
||||
the start of its decompilation (best engine available). `max_items` caps each list \
|
||||
(default 20) and `snippet_lines` the decompilation (default 30); totals say what \
|
||||
was left out."
|
||||
)]
|
||||
async fn function_summary(
|
||||
&self,
|
||||
Parameters(p): Parameters<FunctionSummaryParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_items = clamp_count(p.max_items, 20, 100) as usize;
|
||||
let snippet_lines = clamp_count(p.snippet_lines, 30, 500) as usize;
|
||||
let (addr, info) = self.function_at(&p.at).await?;
|
||||
let refs = |key: &str, kind: &str| -> Vec<u64> {
|
||||
let mut seen = Vec::new();
|
||||
for r in info
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if r.get("type").and_then(Value::as_str) != Some(kind) {
|
||||
continue;
|
||||
}
|
||||
if let Some(to) = r.get("to").and_then(Value::as_u64)
|
||||
&& !seen.contains(&to)
|
||||
{
|
||||
seen.push(to);
|
||||
}
|
||||
}
|
||||
seen
|
||||
};
|
||||
|
||||
let callee_addrs = refs("callrefs", "CALL");
|
||||
let mut callees = Vec::new();
|
||||
for &to in callee_addrs.iter().take(max_items) {
|
||||
callees.push(json!({ "addr": format!("0x{to:x}"), "name": self.name_at(to).await }));
|
||||
}
|
||||
|
||||
let xrefs = self.manager.cmdj(&format!("axtj @ {addr}")).await.mcp()?;
|
||||
let xrefs = xrefs.as_array().cloned().unwrap_or_default();
|
||||
let mut callers = Vec::new();
|
||||
for x in xrefs.iter().take(max_items) {
|
||||
let from = x.get("from").and_then(Value::as_u64).unwrap_or(0);
|
||||
callers.push(json!({
|
||||
"from": format!("0x{from:x}"),
|
||||
"type": x.get("type"),
|
||||
"name": self.name_at(from).await,
|
||||
}));
|
||||
}
|
||||
|
||||
// Data references into the binary's strings. A reference often points
|
||||
// into a string whose tail another string shares, and `ps` would guess
|
||||
// its encoding anew, so the strings come from `izj`.
|
||||
let table = self.cmdj_optional("izj", json!([])).await?;
|
||||
let table: Vec<(u64, u64, &Value)> = table
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| Some((s.get("vaddr")?.as_u64()?, s.get("size")?.as_u64()?, s)))
|
||||
.collect();
|
||||
let mut strings = Vec::new();
|
||||
let mut string_refs = 0;
|
||||
for to in refs("datarefs", "DATA") {
|
||||
let Some((start, _, entry)) = table
|
||||
.iter()
|
||||
.find(|(start, size, _)| (*start..start + size).contains(&to))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
string_refs += 1;
|
||||
if strings.len() >= max_items {
|
||||
continue;
|
||||
}
|
||||
let text = entry.get("string").and_then(Value::as_str).unwrap_or("");
|
||||
let offset = (to - start) as usize;
|
||||
let ascii = matches!(
|
||||
entry.get("type").and_then(Value::as_str),
|
||||
Some("ascii" | "utf8")
|
||||
);
|
||||
// The referenced tail, when byte offsets are character offsets.
|
||||
let tail = text.get(offset..).filter(|_| ascii);
|
||||
strings.push(json!({
|
||||
"addr": format!("0x{to:x}"),
|
||||
"string": tail.unwrap_or(text),
|
||||
"offset_in_string": if tail.is_some() { 0 } else { offset },
|
||||
}));
|
||||
}
|
||||
|
||||
let snippet = match self.decompile_text(&format!(" @ {addr}"), "auto").await {
|
||||
Ok((engine, text, _)) => {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
json!({
|
||||
"engine": engine,
|
||||
"total_lines": lines.len(),
|
||||
"text": lines[..lines.len().min(snippet_lines)].join("\n"),
|
||||
})
|
||||
}
|
||||
Err(e) => json!({ "error": e.message }),
|
||||
};
|
||||
|
||||
let field = |k: &str| info.get(k).cloned().unwrap_or(Value::Null);
|
||||
Ok(self.json_result(json!({
|
||||
"name": field("name"),
|
||||
"addr": addr,
|
||||
"size": field("size"),
|
||||
"blocks": field("nbbs"),
|
||||
"cyclomatic_complexity": field("cc"),
|
||||
"args": field("nargs"),
|
||||
"locals": field("nlocals"),
|
||||
"signature": field("signature"),
|
||||
"noreturn": field("noreturn"),
|
||||
"callers": { "total": xrefs.len(), "items": callers },
|
||||
"callees": { "total": callee_addrs.len(), "items": callees },
|
||||
"strings": { "total": string_refs, "items": strings },
|
||||
"decompiled": snippet,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Function info (`afij`).
|
||||
#[tool(
|
||||
description = "Detailed info for the function at a location: bounds, stack frame, \
|
||||
@@ -862,79 +1072,8 @@ impl RizinServer {
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let suffix = at_suffix(&p.at).mcp()?;
|
||||
let which = p.decompiler.as_deref().unwrap_or("auto").to_lowercase();
|
||||
|
||||
// (engine name, command) candidates for each selection.
|
||||
let ghidra = ("ghidra", format!("pdg{suffix}"));
|
||||
let jsdec = ("jsdec", format!("pdd{suffix}"));
|
||||
// Pseudo-disassembly of the whole function, with temporary evals so the
|
||||
// global settings are left untouched. Without the jump arrows
|
||||
// (`asm.lines`), which Rizin omits from captured in-process output, the
|
||||
// text is the same on both backends. `pdr` walks the basic blocks:
|
||||
// `pdf` spends seconds per pointer operand in a large function.
|
||||
let pseudo = (
|
||||
"pseudo",
|
||||
format!("pdr @e:asm.pseudo=true @e:asm.lines=false{suffix}"),
|
||||
);
|
||||
|
||||
let candidates: Vec<(&str, String)> = match which.as_str() {
|
||||
"auto" => vec![ghidra, jsdec, pseudo],
|
||||
"ghidra" => vec![ghidra],
|
||||
"jsdec" => vec![jsdec],
|
||||
"pseudo" => vec![pseudo],
|
||||
other => {
|
||||
return Err(RizinError::InvalidArgument {
|
||||
arg: other.to_string(),
|
||||
reason: "decompiler must be auto, ghidra, jsdec, or pseudo".into(),
|
||||
}
|
||||
.to_error_data());
|
||||
}
|
||||
};
|
||||
|
||||
// Only a missing plugin (or an empty result) moves on to the next
|
||||
// engine: an engine that ran and failed (e.g. no function there) would
|
||||
// fail the same way in the next one, and a timeout or dead session
|
||||
// must not be retried.
|
||||
let mut skipped = serde_json::Map::new();
|
||||
for (engine, cmd) in &candidates {
|
||||
let out = self.manager.exec(cmd).await.mcp()?;
|
||||
match crate::backend::check(cmd, out) {
|
||||
Ok(out) if !out.output.trim().is_empty() => {
|
||||
return Ok(self.text_result_meta(
|
||||
out.output,
|
||||
json!({ "decompiler": engine, "skipped": skipped }),
|
||||
));
|
||||
}
|
||||
// A pipe to Rizin before 0.10 sees a failure only as no output
|
||||
// and an error message.
|
||||
Ok(out) if out.messages.iter().any(|m| m.starts_with("ERROR")) => {
|
||||
return Err(RizinError::CommandFailed {
|
||||
cmd: cmd.clone(),
|
||||
status: out.status,
|
||||
detail: out.detail(),
|
||||
}
|
||||
.to_error_data());
|
||||
}
|
||||
Ok(_) => {
|
||||
skipped.insert(engine.to_string(), json!("no output"));
|
||||
}
|
||||
Err(RizinError::CommandFailed {
|
||||
status: CmdStatus::NonExistingCmd,
|
||||
..
|
||||
}) => {
|
||||
skipped.insert(engine.to_string(), json!("not installed"));
|
||||
}
|
||||
Err(e) => return Err(e.to_error_data()),
|
||||
}
|
||||
}
|
||||
|
||||
let missing = skipped.values().any(|v| v == "not installed");
|
||||
let hint = if missing {
|
||||
"no decompiler output: the requested engine is not installed. Install the \
|
||||
rz-ghidra (pdg) or jsdec (pdd) plugin, or use decompiler=\"pseudo\"."
|
||||
} else {
|
||||
"no decompiler output for the function at this location."
|
||||
};
|
||||
Err(RizinError::NotPermitted(hint.into()).to_error_data())
|
||||
let (engine, text, skipped) = self.decompile_text(&suffix, &which).await?;
|
||||
Ok(self.text_result_meta(text, json!({ "decompiler": engine, "skipped": skipped })))
|
||||
}
|
||||
|
||||
/// Report which decompiler engines are available (`Lcj`).
|
||||
@@ -2042,7 +2181,8 @@ impl RizinServer {
|
||||
PromptMessageRole::User,
|
||||
format!(
|
||||
"Analyze the function at {addr} in depth. Start with \
|
||||
`function_info {{\"at\": \"{addr}\"}}` for its bounds and signature, then \
|
||||
`function_summary {{\"at\": \"{addr}\"}}` for its signature, callers, \
|
||||
callees and strings, then \
|
||||
`disassemble_function` and `decompile`. Use `function_variables` for locals \
|
||||
and arguments, `xrefs_to` to see callers and `xrefs_from`/`function_graph` to \
|
||||
understand its behavior. Explain what the function does, its inputs and \
|
||||
|
||||
@@ -866,3 +866,70 @@ async fn work_can_be_exported_and_saved() {
|
||||
let _ = std::fs::remove_file(&project);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn function_summary_aggregates_in_one_call() {
|
||||
for t in transports() {
|
||||
let s = Session::start(t, |_| {}).await;
|
||||
s.ok(
|
||||
"open_file",
|
||||
json!({ "path": target(), "analysis": "basic" }),
|
||||
)
|
||||
.await;
|
||||
let started = std::time::Instant::now();
|
||||
let r = s
|
||||
.ok("function_summary", json!({ "at": "main", "max_items": 5 }))
|
||||
.await;
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_secs(10),
|
||||
"{t:?}: {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
assert_eq!(r["name"], "main", "{t:?}: {r}");
|
||||
assert!(r["signature"].as_str().unwrap().contains("main"), "{t:?}");
|
||||
assert!(r["blocks"].as_u64().unwrap() > 1, "{t:?}");
|
||||
for list in ["callers", "callees", "strings"] {
|
||||
let items = r[list]["items"].as_array().unwrap();
|
||||
assert!(items.len() <= 5, "{t:?}: {list}");
|
||||
assert!(
|
||||
r[list]["total"].as_u64().unwrap() >= items.len() as u64,
|
||||
"{t:?}: {list}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
r["callees"]["total"].as_u64().unwrap() > 5,
|
||||
"{t:?}: main calls many functions"
|
||||
);
|
||||
assert!(
|
||||
!r["callees"]["items"][0]["name"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"{t:?}"
|
||||
);
|
||||
// The strings come from the string table, not from re-guessing the
|
||||
// encoding at the reference (which turned ASCII into UTF-16 garbage).
|
||||
let strings: Vec<&str> = r["strings"]["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s["string"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(strings.iter().any(|s| !s.is_empty()), "{t:?}: {strings:?}");
|
||||
assert!(strings.iter().all(|s| s.is_ascii()), "{t:?}: {strings:?}");
|
||||
let snippet = &r["decompiled"];
|
||||
assert_eq!(snippet["engine"], "pseudo", "{t:?}: {snippet}");
|
||||
assert!(snippet["text"].as_str().unwrap().lines().count() <= 30);
|
||||
assert!(snippet["total_lines"].as_u64().unwrap() > 30, "{t:?}");
|
||||
|
||||
let err = s
|
||||
.call("function_summary", json!({ "at": "0x0" }))
|
||||
.await
|
||||
.expect_err("no function");
|
||||
assert!(
|
||||
err.message.contains("no function"),
|
||||
"{t:?}: {}",
|
||||
err.message
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user