Skip to main content

ghidra_ipc/
error.rs

1//! Error envelope + code set (spec §10). Serialized as the MCP tool result body
2//! `{ error: { code, message, suggested_action, details? } }` with isError:true
3//! (the isError flagging is applied by the MCP layer in M1).
4
5use serde::{Deserialize, Serialize};
6
7/// v1 error codes (spec §10, plus DEPRECATED_TOOL from §7). SCREAMING_SNAKE_CASE
8/// on the wire. SCRIPT_DISABLED / JOB_NOT_FOUND are v1.1 and intentionally absent.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
11pub enum ErrorCode {
12    ProgramNotOpen,
13    ProgramNotFound,
14    ProjectNotFound,
15    ProjectLocked,
16    AddressNotFound,
17    NotAFunction,
18    AmbiguousSymbol,
19    SymbolNotFound,
20    VariableNotFound,
21    DatatypeNotFound,
22    AmbiguousDatatype,
23    RenameConflict,
24    StateConflict,
25    InvalidParams,
26    WorkerWarming,
27    WorkerUnavailable,
28    WorkerRestarted,
29    WorkerIncompatible,
30    TransactionFailed,
31    DecompileTimeout,
32    GhidraNotFound,
33    JdkNotFound,
34    DeprecatedTool,
35    FrameTooLarge,
36    DecompilationFailed,
37    ServerBusy,
38    ParseError,
39}
40
41impl ErrorCode {
42    /// The fixed, actionable guidance for each code (spec §10). Three strings are
43    /// pinned verbatim by the spec (ProjectLocked §8.1, WorkerRestarted/WorkerWarming §4).
44    pub fn suggested_action(self) -> &'static str {
45        match self {
46            ErrorCode::ProgramNotOpen => "attach the program first with attach_program, then retry",
47            ErrorCode::ProgramNotFound => "the program VFS path was not found in the project; call list_project_programs to list valid paths",
48            ErrorCode::ProjectNotFound => "check project_path points at an existing Ghidra project (.gpr / project dir)",
49            ErrorCode::ProjectLocked => "close this project in the Ghidra GUI, then retry",
50            ErrorCode::AddressNotFound => "the address is not mapped in this program; verify it with a read tool",
51            ErrorCode::NotAFunction => "no function is defined at this address; use describe_address to identify it or find_functions to search",
52            ErrorCode::AmbiguousSymbol => "the name resolves to multiple symbols; call resolve_symbol and pass an address",
53            ErrorCode::SymbolNotFound => "no symbol matches that name; call resolve_symbol or list_symbols",
54            ErrorCode::VariableNotFound => "no local variable matches that storage or name in this function; call inspect_function to re-read its locals[], then retry with a listed storage handle",
55            ErrorCode::DatatypeNotFound => "no data type matches that name; check the exact type name",
56            ErrorCode::AmbiguousDatatype => "several data types share that name; re-call get_datatype with the category-path-qualified name shown in details.candidates",
57            ErrorCode::RenameConflict => "the symbol's current name differs from expected_name; re-read with describe_address or resolve_symbol and retry with the actual current name shown in details.actual",
58            ErrorCode::StateConflict => "the target's current value differs from your expected_ guard; re-read (describe_address / inspect_function / get_datatype), then retry with the actual value shown in details.actual, or omit the guard",
59            ErrorCode::InvalidParams => "one or more parameters are invalid; check the tool's parameter schema",
60            ErrorCode::WorkerWarming => "the Ghidra worker is still starting; wait at least 10 s before retrying",
61            ErrorCode::WorkerUnavailable => "the Ghidra worker is temporarily unavailable; retry shortly",
62            ErrorCode::WorkerRestarted => "worker crashed and restarted; unsaved edits since last save were lost — re-verify state",
63            ErrorCode::WorkerIncompatible => "the worker script is incompatible with this host; reinstall the plugin",
64            ErrorCode::TransactionFailed => "the edit was rolled back and the program is unchanged; re-read state and retry",
65            ErrorCode::DecompileTimeout => "decompilation exceeded the time budget; raise the timeout or treat the function as opaque",
66            ErrorCode::GhidraNotFound => "GHIDRA_INSTALL_DIR is unset or invalid; point it at your Ghidra install root",
67            ErrorCode::JdkNotFound => "a JDK 21 runtime for Ghidra was not found; install JDK 21 and retry",
68            ErrorCode::DeprecatedTool => "this tool is deprecated; call the replacement named in the message",
69            ErrorCode::FrameTooLarge => "the response exceeded the 16 MiB frame limit; lower max_c_lines and retry",
70            ErrorCode::DecompilationFailed => "the decompiler produced no output for this function; treat it as opaque or inspect a caller instead",
71            ErrorCode::ServerBusy => "the worker is single-threaded and at capacity; issue your tool calls one at a time and retry",
72            ErrorCode::ParseError => "the worker returned malformed data; the connection was reset — retry the call",
73        }
74    }
75}
76
77/// The error payload (spec §10): `{ code, message, suggested_action, details? }`.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ErrorPayload {
80    pub code: ErrorCode,
81    pub message: String,
82    pub suggested_action: String,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub details: Option<serde_json::Value>,
85}
86
87/// The full envelope serialized to the MCP tool result: `{ error: { … } }`.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ErrorEnvelope {
90    pub error: ErrorPayload,
91}
92
93impl ErrorEnvelope {
94    /// Build an envelope, filling in the code's fixed `suggested_action`.
95    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
96        Self {
97            error: ErrorPayload {
98                code,
99                message: message.into(),
100                suggested_action: code.suggested_action().to_string(),
101                details: None,
102            },
103        }
104    }
105
106    pub fn with_details(mut self, details: serde_json::Value) -> Self {
107        self.error.details = Some(details);
108        self
109    }
110
111    /// Override the code's default `suggested_action` with a call-site-specific one. Used where the
112    /// pinned action names a not-yet-shipped tool (M1a has no function-search tool, so NotAFunction /
113    /// AmbiguousSymbol must not send the agent to find_functions/resolve_symbol — spec §4.2).
114    pub fn with_action(mut self, action: impl Into<String>) -> Self {
115        self.error.suggested_action = action.into();
116        self
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    // The complete v1 code set (spec §10 + DEPRECATED_TOOL from §7). No elision.
125    const ALL: [ErrorCode; 27] = [
126        ErrorCode::ProgramNotOpen,
127        ErrorCode::ProgramNotFound,
128        ErrorCode::ProjectNotFound,
129        ErrorCode::ProjectLocked,
130        ErrorCode::AddressNotFound,
131        ErrorCode::NotAFunction,
132        ErrorCode::AmbiguousSymbol,
133        ErrorCode::SymbolNotFound,
134        ErrorCode::VariableNotFound,
135        ErrorCode::DatatypeNotFound,
136        ErrorCode::AmbiguousDatatype,
137        ErrorCode::RenameConflict,
138        ErrorCode::StateConflict,
139        ErrorCode::InvalidParams,
140        ErrorCode::WorkerWarming,
141        ErrorCode::WorkerUnavailable,
142        ErrorCode::WorkerRestarted,
143        ErrorCode::WorkerIncompatible,
144        ErrorCode::TransactionFailed,
145        ErrorCode::DecompileTimeout,
146        ErrorCode::GhidraNotFound,
147        ErrorCode::JdkNotFound,
148        ErrorCode::DeprecatedTool,
149        ErrorCode::FrameTooLarge,
150        ErrorCode::DecompilationFailed,
151        ErrorCode::ServerBusy,
152        ErrorCode::ParseError,
153    ];
154
155    #[test]
156    fn codes_serialize_screaming_snake_case() {
157        assert_eq!(
158            serde_json::to_string(&ErrorCode::ProgramNotOpen).unwrap(),
159            "\"PROGRAM_NOT_OPEN\""
160        );
161        assert_eq!(
162            serde_json::to_string(&ErrorCode::DecompileTimeout).unwrap(),
163            "\"DECOMPILE_TIMEOUT\""
164        );
165        assert_eq!(
166            serde_json::to_string(&ErrorCode::JdkNotFound).unwrap(),
167            "\"JDK_NOT_FOUND\""
168        );
169    }
170
171    #[test]
172    fn every_code_has_a_nonempty_suggested_action() {
173        for code in ALL {
174            assert!(
175                !code.suggested_action().is_empty(),
176                "empty action for {code:?}"
177            );
178        }
179    }
180
181    #[test]
182    fn spec_fixed_actions_match_verbatim() {
183        assert_eq!(
184            ErrorCode::ProjectLocked.suggested_action(),
185            "close this project in the Ghidra GUI, then retry"
186        );
187        assert_eq!(
188            ErrorCode::WorkerRestarted.suggested_action(),
189            "worker crashed and restarted; unsaved edits since last save were lost — re-verify state"
190        );
191        // WORKER_WARMING must instruct an explicit wait, not an immediate retry (spec §4).
192        assert!(ErrorCode::WorkerWarming.suggested_action().contains("wait"));
193    }
194
195    #[test]
196    fn envelope_carries_code_action_and_omits_absent_details() {
197        let env = ErrorEnvelope::new(ErrorCode::ProjectLocked, "project /p is locked");
198        let json = serde_json::to_value(&env).unwrap();
199        assert_eq!(json["error"]["code"], "PROJECT_LOCKED");
200        assert_eq!(json["error"]["message"], "project /p is locked");
201        assert_eq!(
202            json["error"]["suggested_action"],
203            "close this project in the Ghidra GUI, then retry"
204        );
205        assert!(json["error"].get("details").is_none());
206    }
207
208    #[test]
209    fn envelope_with_details_roundtrips() {
210        let env = ErrorEnvelope::new(ErrorCode::AmbiguousSymbol, "main matches 3 symbols")
211            .with_details(serde_json::json!({ "candidates": 3 }));
212        let s = serde_json::to_string(&env).unwrap();
213        let back: ErrorEnvelope = serde_json::from_str(&s).unwrap();
214        assert_eq!(back.error.code, ErrorCode::AmbiguousSymbol);
215        assert_eq!(back.error.details.unwrap()["candidates"], 3);
216    }
217
218    #[test]
219    fn envelope_snapshot() {
220        let env = ErrorEnvelope::new(
221            ErrorCode::DecompileTimeout,
222            "decompile of FUN_00401230 timed out",
223        );
224        insta::assert_json_snapshot!(env);
225    }
226
227    #[test]
228    fn new_m1a_variants_serialize_screaming_snake_case() {
229        assert_eq!(
230            serde_json::to_string(&ErrorCode::FrameTooLarge).unwrap(),
231            "\"FRAME_TOO_LARGE\""
232        );
233        assert_eq!(
234            serde_json::to_string(&ErrorCode::DecompilationFailed).unwrap(),
235            "\"DECOMPILATION_FAILED\""
236        );
237        assert_eq!(
238            serde_json::to_string(&ErrorCode::ServerBusy).unwrap(),
239            "\"SERVER_BUSY\""
240        );
241        assert_eq!(
242            serde_json::to_string(&ErrorCode::ParseError).unwrap(),
243            "\"PARSE_ERROR\""
244        );
245        assert_eq!(
246            serde_json::to_string(&ErrorCode::AmbiguousDatatype).unwrap(),
247            "\"AMBIGUOUS_DATATYPE\""
248        );
249    }
250
251    #[test]
252    fn with_action_overrides_the_pinned_action() {
253        let env = ErrorEnvelope::new(ErrorCode::NotAFunction, "no function 'foo'").with_action(
254            "supply a known address or exact name, or navigate callers/callees from a known entry point",
255        );
256        let json = serde_json::to_value(&env).unwrap();
257        assert_eq!(
258            json["error"]["suggested_action"],
259            "supply a known address or exact name, or navigate callers/callees from a known entry point"
260        );
261    }
262
263    #[test]
264    fn retired_bridge_actions_name_shipped_tools() {
265        assert!(ErrorCode::AmbiguousSymbol
266            .suggested_action()
267            .contains("resolve_symbol"));
268        assert!(ErrorCode::NotAFunction
269            .suggested_action()
270            .contains("find_functions"));
271    }
272
273    #[test]
274    fn rename_conflict_serializes_screaming_snake() {
275        assert_eq!(
276            serde_json::to_string(&ErrorCode::RenameConflict).unwrap(),
277            "\"RENAME_CONFLICT\""
278        );
279    }
280
281    #[test]
282    fn state_conflict_serializes_screaming_snake() {
283        assert_eq!(
284            serde_json::to_string(&ErrorCode::StateConflict).unwrap(),
285            "\"STATE_CONFLICT\""
286        );
287        assert!(!ErrorCode::StateConflict.suggested_action().is_empty());
288    }
289
290    #[test]
291    fn variable_not_found_serializes_screaming_snake() {
292        assert_eq!(
293            serde_json::to_string(&ErrorCode::VariableNotFound).unwrap(),
294            "\"VARIABLE_NOT_FOUND\""
295        );
296        assert!(!ErrorCode::VariableNotFound.suggested_action().is_empty());
297    }
298}