Skip to main content

re_ghidra_mcp_agy/
lib.rs

1//! Antigravity-specific glue for the `re-ghidra-mcp-agy` plugin.
2//!
3//! The server, its 19 tools and the Ghidra worker all live in the shared
4//! `ghidra-mcp` crate. What is genuinely Antigravity-shaped lives here: reading
5//! `.agents/re-ghidra-mcp-agy.local.md`, and the preflight the `PreInvocation`
6//! hook reports.
7//!
8//! Why a settings file matters more for this plugin than for most: **one server
9//! is one Ghidra project.** The project directory, project name and bootstrap
10//! program are per-workspace by nature, so without a settings layer every repo
11//! needs a hand-written `.mcp.json` that duplicates the plugin's registration
12//! just to carry three strings.
13
14use ghidra_mcp::config::RawConfig;
15use std::path::{Path, PathBuf};
16
17// ── Settings ───────────────────────────────────────────────────────────
18
19/// The settings-file layer, in the shape the shared config resolver consumes.
20pub fn settings_from_markdown(source: &str) -> RawConfig {
21    let mut config = RawConfig::default();
22    let Some(frontmatter) = extract_frontmatter(source) else {
23        return config;
24    };
25
26    for line in frontmatter.lines() {
27        let line = line.trim();
28        if line.is_empty() || line.starts_with('#') {
29            continue;
30        }
31        let Some((key, value)) = line.split_once(':') else {
32            continue;
33        };
34        let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
35        if value.is_empty() {
36            continue;
37        }
38        let value = Some(value.to_string());
39        match key.trim() {
40            "ghidra_install_dir" => config.ghidra_install_dir = value,
41            "project_dir" => config.project_dir = value,
42            "project_name" => config.project_name = value,
43            "bootstrap_program" => config.bootstrap_program = value,
44            "bootstrap_program_path" => config.bootstrap_program_path = value,
45            "max_heap" => config.max_heap = value,
46            _ => {}
47        }
48    }
49    config
50}
51
52/// The settings file this plugin reads, relative to a project root.
53pub fn settings_path(project_dir: &Path) -> PathBuf {
54    project_dir
55        .join(".agents")
56        .join("re-ghidra-mcp-agy.local.md")
57}
58
59/// Load the settings layer for `project_dir`. A missing or unreadable file is
60/// not an error — it simply contributes nothing, and the environment or CLI
61/// flags supply the values instead.
62pub fn load_settings(project_dir: &Path) -> RawConfig {
63    match std::fs::read_to_string(settings_path(project_dir)) {
64        Ok(text) => settings_from_markdown(&text),
65        Err(_) => RawConfig::default(),
66    }
67}
68
69/// Extract the YAML frontmatter block from a `.local.md` file.
70fn extract_frontmatter(source: &str) -> Option<&str> {
71    let source = source.strip_prefix('\u{feff}').unwrap_or(source);
72    let rest = source.strip_prefix("---")?;
73    let rest = rest.strip_prefix('\r').unwrap_or(rest);
74    let rest = rest.strip_prefix('\n')?;
75    let end = rest.find("\n---")?;
76    Some(&rest[..end])
77}
78
79// ── Preflight ──────────────────────────────────────────────────────────
80
81#[derive(Debug, Clone, Default)]
82pub struct Probe {
83    pub launcher_present: bool,
84    pub java_major: Option<u32>,
85}
86
87pub const MIN_JAVA_MAJOR: u32 = 21;
88
89pub fn preflight(cfg: &RawConfig, probe: &Probe) -> Vec<String> {
90    let mut findings = Vec::new();
91
92    match cfg.ghidra_install_dir.as_deref() {
93        None => findings.push(
94            "GHIDRA_INSTALL_DIR is not set. Point it at your Ghidra install root \
95             (the directory containing `support/`)."
96                .to_string(),
97        ),
98        Some(dir) if !probe.launcher_present => findings.push(format!(
99            "GHIDRA_INSTALL_DIR is set to `{dir}` but there is no \
100             `support/analyzeHeadless` there, so it is not a Ghidra install root."
101        )),
102        Some(_) => {}
103    }
104
105    match probe.java_major {
106        None => findings.push(
107            "No `java` on PATH. Ghidra 12.1.2 requires JDK 21 \
108             (`application.java.min=21`)."
109                .to_string(),
110        ),
111        Some(v) if v < MIN_JAVA_MAJOR => findings.push(format!(
112            "`java` on PATH reports version {v}; Ghidra 12.1.2 requires \
113             JDK {MIN_JAVA_MAJOR} or newer."
114        )),
115        Some(_) => {}
116    }
117
118    let missing: Vec<&str> = [
119        ("project_dir", cfg.project_dir.is_none()),
120        ("project_name", cfg.project_name.is_none()),
121        ("bootstrap_program", cfg.bootstrap_program.is_none()),
122    ]
123    .into_iter()
124    .filter_map(|(name, absent)| absent.then_some(name))
125    .collect();
126
127    if !missing.is_empty() {
128        findings.push(format!(
129            "Unset: {}. Set them in `.agents/re-ghidra-mcp-agy.local.md` \
130             or as GHIDRA_MCP_PROJECT_DIR / GHIDRA_MCP_PROJECT_NAME / \
131             GHIDRA_MCP_BOOTSTRAP_PROGRAM.",
132            missing.join(", ")
133        ));
134    }
135
136    findings
137}
138
139pub fn parse_java_major(version_output: &str) -> Option<u32> {
140    let quoted = version_output.split('"').nth(1)?;
141    let mut parts = quoted.split(['.', '_', '-']);
142    let first: u32 = parts.next()?.parse().ok()?;
143    if first == 1 {
144        parts.next()?.parse().ok()
145    } else {
146        Some(first)
147    }
148}
149
150#[cfg(test)]
151mod preflight_tests {
152    use super::*;
153
154    fn complete() -> RawConfig {
155        RawConfig {
156            ghidra_install_dir: Some("C:/ghidra".into()),
157            project_dir: Some("C:/re".into()),
158            project_name: Some("p".into()),
159            bootstrap_program: Some("add.exe".into()),
160            ..Default::default()
161        }
162    }
163
164    fn good_probe() -> Probe {
165        Probe {
166            launcher_present: true,
167            java_major: Some(21),
168        }
169    }
170
171    /// The silent path. If this ever starts reporting something, the hook goes
172    /// from invisible to noisy on every single session.
173    #[test]
174    fn fully_configured_reports_nothing() {
175        assert!(preflight(&complete(), &good_probe()).is_empty());
176    }
177
178    #[test]
179    fn install_dir_set_but_not_a_ghidra_root_is_reported() {
180        let probe = Probe {
181            launcher_present: false,
182            ..good_probe()
183        };
184        let f = preflight(&complete(), &probe);
185        assert_eq!(f.len(), 1);
186        assert!(f[0].contains("analyzeHeadless"), "{}", f[0]);
187    }
188
189    #[test]
190    fn old_java_is_reported_with_its_version() {
191        let probe = Probe {
192            java_major: Some(17),
193            ..good_probe()
194        };
195        let f = preflight(&complete(), &probe);
196        assert_eq!(f.len(), 1);
197        assert!(f[0].contains("17") && f[0].contains("21"), "{}", f[0]);
198    }
199
200    #[test]
201    fn absent_java_is_reported() {
202        let probe = Probe {
203            java_major: None,
204            ..good_probe()
205        };
206        assert!(preflight(&complete(), &probe)[0].contains("No `java` on PATH"));
207    }
208
209    /// The three per-project values collapse into one finding, naming exactly
210    /// the ones that are missing.
211    #[test]
212    fn missing_project_values_are_one_finding_naming_each() {
213        let cfg = RawConfig {
214            project_name: None,
215            bootstrap_program: None,
216            ..complete()
217        };
218        let f = preflight(&cfg, &good_probe());
219        assert_eq!(f.len(), 1);
220        assert!(f[0].contains("project_name"), "{}", f[0]);
221        assert!(f[0].contains("bootstrap_program"), "{}", f[0]);
222        assert!(!f[0].contains("project_dir,"), "{}", f[0]);
223    }
224
225    /// The agy finding names the agy settings file, not the Claude one — the
226    /// one intentional content difference between the fronts.
227    #[test]
228    fn missing_project_values_name_the_agy_settings_file() {
229        let cfg = RawConfig {
230            project_dir: None,
231            ..complete()
232        };
233        let f = preflight(&cfg, &good_probe());
234        assert_eq!(f.len(), 1);
235        assert!(
236            f[0].contains(".agents/re-ghidra-mcp-agy.local.md"),
237            "{}",
238            f[0]
239        );
240    }
241
242    #[test]
243    fn parses_modern_and_legacy_java_version_strings() {
244        assert_eq!(
245            parse_java_major("openjdk version \"21.0.2\" 2024-01-16"),
246            Some(21)
247        );
248        assert_eq!(
249            parse_java_major("java version \"1.8.0_412\"\nJava(TM) SE Runtime"),
250            Some(8)
251        );
252        assert_eq!(
253            parse_java_major("openjdk version \"25\" 2025-09-16"),
254            Some(25)
255        );
256    }
257
258    /// Unparseable output must read as "unknown", never as a low version — the
259    /// latter would produce a confidently wrong "upgrade your JDK" message.
260    #[test]
261    fn unrecognized_version_output_is_none() {
262        assert_eq!(parse_java_major("command not found"), None);
263        assert_eq!(parse_java_major("openjdk version \"weird\""), None);
264        assert_eq!(parse_java_major(""), None);
265    }
266}
267
268#[cfg(test)]
269mod settings_tests {
270    use super::*;
271
272    const SAMPLE: &str = "---\nproject_dir: C:\\re\\projects\nproject_name: crackme\nbootstrap_program: add.exe\n---\n\n# notes\n";
273
274    #[test]
275    fn reads_the_frontmatter_keys() {
276        let c = settings_from_markdown(SAMPLE);
277        assert_eq!(c.project_dir.as_deref(), Some("C:\\re\\projects"));
278        assert_eq!(c.project_name.as_deref(), Some("crackme"));
279        assert_eq!(c.bootstrap_program.as_deref(), Some("add.exe"));
280        assert_eq!(c.max_heap, None);
281    }
282
283    #[test]
284    fn crlf_and_bom_still_parse() {
285        let crlf = format!("\u{feff}{}", SAMPLE.replace('\n', "\r\n"));
286        let c = settings_from_markdown(&crlf);
287        assert_eq!(c.project_name.as_deref(), Some("crackme"));
288    }
289
290    /// A file with no frontmatter, or an unterminated block, contributes
291    /// nothing rather than half-parsing into a confusing partial config.
292    #[test]
293    fn no_frontmatter_yields_nothing() {
294        assert!(settings_from_markdown("# just a heading\n")
295            .project_name
296            .is_none());
297        assert!(settings_from_markdown("---\nproject_name: x\n")
298            .project_name
299            .is_none());
300    }
301
302    /// A key present but blank must not resolve to an empty project name — that
303    /// would pass the "is it set?" check and then fail deep inside Ghidra.
304    #[test]
305    fn blank_value_is_not_a_value() {
306        assert!(settings_from_markdown("---\nproject_name:   \n---\n")
307            .project_name
308            .is_none());
309    }
310
311    #[test]
312    fn unknown_keys_are_ignored() {
313        let c = settings_from_markdown("---\nfuture_key: 1\nproject_name: p\n---\n");
314        assert_eq!(c.project_name.as_deref(), Some("p"));
315    }
316
317    /// Pins the one path that legitimately differs per agent.
318    #[test]
319    fn settings_path_is_the_agy_file() {
320        let p = settings_path(Path::new("C:/work/proj"));
321        assert_eq!(
322            p,
323            Path::new("C:/work/proj/.agents/re-ghidra-mcp-agy.local.md")
324        );
325    }
326}