Skip to main content

re_ghidra_mcp_cc/
lib.rs

1//! Claude-Code-specific glue for the `re-ghidra-mcp-cc` plugin.
2//!
3//! The server, its 19 tools and the Ghidra worker all live in the shared
4//! `ghidra-mcp` crate. What is genuinely Claude-Code-shaped lives here: reading
5//! the host's settings file — `.claude/re-ghidra-mcp-cc.local.md` on Claude,
6//! `.opencode/re-ghidra-mcp-opencode.local.md` on OpenCode, selected by
7//! `RE_GHIDRA_HOST` — and the preflight the `SessionStart` hook reports.
8//!
9//! Why a settings file matters more for this plugin than for most: **one server
10//! is one Ghidra project.** The project directory, project name and bootstrap
11//! program are per-workspace by nature, so without a settings layer every repo
12//! needs a hand-written `.mcp.json` that duplicates the plugin's registration
13//! just to carry three strings.
14
15use ghidra_mcp::config::RawConfig;
16use std::path::{Path, PathBuf};
17
18// ── Host ───────────────────────────────────────────────────────────────
19
20/// Which agent front is running. The settings file is per-host: a session on
21/// one host must never read the other host's file, which is what makes
22/// `.opencode/…local.md` and `.claude/…local.md` disjoint rather than a
23/// migration path between them.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Host {
26    Claude,
27    Opencode,
28}
29
30impl Host {
31    /// The settings file this host reads, relative to a project root.
32    pub fn settings_rel_path(self) -> &'static str {
33        match self {
34            Host::Claude => ".claude/re-ghidra-mcp-cc.local.md",
35            Host::Opencode => ".opencode/re-ghidra-mcp-opencode.local.md",
36        }
37    }
38}
39
40/// The host named by `RE_GHIDRA_HOST`. Unset or anything but exactly
41/// `"opencode"` means [`Host::Claude`]: the Claude front has never read this
42/// variable, so defaulting there keeps every existing install on today's
43/// behavior. OpenCode sets the variable in `opencode.jsonc`'s
44/// `mcp.servers.ghidra.environment` (Task 5).
45pub fn host_from_str(value: Option<&str>) -> Host {
46    match value {
47        Some("opencode") => Host::Opencode,
48        _ => Host::Claude,
49    }
50}
51
52/// [`host_from_str`] over the process environment.
53pub fn host_from_env() -> Host {
54    host_from_str(std::env::var("RE_GHIDRA_HOST").ok().as_deref())
55}
56
57// ── Settings ───────────────────────────────────────────────────────────
58
59/// The settings-file layer, in the shape the shared config resolver consumes.
60///
61/// Precedence is decided in `ghidra_mcp::cli::layered_config`, not here: CLI
62/// flag, then environment, then this. Returning a [`RawConfig`] rather than a
63/// bespoke struct is what keeps that single rule single.
64pub fn settings_from_markdown(source: &str) -> RawConfig {
65    let mut config = RawConfig::default();
66    let Some(frontmatter) = extract_frontmatter(source) else {
67        return config;
68    };
69
70    for line in frontmatter.lines() {
71        let line = line.trim();
72        if line.is_empty() || line.starts_with('#') {
73            continue;
74        }
75        let Some((key, value)) = line.split_once(':') else {
76            continue;
77        };
78        let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
79        if value.is_empty() {
80            continue;
81        }
82        let value = Some(value.to_string());
83        match key.trim() {
84            "ghidra_install_dir" => config.ghidra_install_dir = value,
85            "project_dir" => config.project_dir = value,
86            "project_name" => config.project_name = value,
87            "bootstrap_program" => config.bootstrap_program = value,
88            "bootstrap_program_path" => config.bootstrap_program_path = value,
89            "max_heap" => config.max_heap = value,
90            // An unknown key is ignored rather than fatal: a settings file
91            // written against a newer plugin must not stop the server booting.
92            _ => {}
93        }
94    }
95    config
96}
97
98/// The settings file this plugin reads, relative to a project root.
99pub fn settings_path(project_dir: &Path, host: Host) -> PathBuf {
100    project_dir.join(host.settings_rel_path())
101}
102
103/// Load the settings layer for `project_dir`. A missing or unreadable file is
104/// not an error — it simply contributes nothing, and the environment or CLI
105/// flags supply the values instead.
106///
107/// This is the lowest config layer, and the file it reads is chosen by
108/// `host`: a session never sees the other host's settings.
109pub fn load_settings(project_dir: &Path, host: Host) -> RawConfig {
110    match std::fs::read_to_string(settings_path(project_dir, host)) {
111        Ok(text) => settings_from_markdown(&text),
112        Err(_) => RawConfig::default(),
113    }
114}
115
116/// Extract the YAML frontmatter block from a `.local.md` file.
117///
118/// Tolerates a leading BOM and CRLF line endings: the file is hand-edited on
119/// Windows, where both are routine, and a settings file that silently stops
120/// being read because an editor added a BOM is a miserable thing to debug.
121fn extract_frontmatter(source: &str) -> Option<&str> {
122    let source = source.strip_prefix('\u{feff}').unwrap_or(source);
123    let rest = source.strip_prefix("---")?;
124    let rest = rest.strip_prefix('\r').unwrap_or(rest);
125    let rest = rest.strip_prefix('\n')?;
126    let end = rest.find("\n---")?;
127    Some(&rest[..end])
128}
129
130// ── Preflight ──────────────────────────────────────────────────────────
131
132/// What the environment looks like to the preflight, gathered by the caller.
133///
134/// The checks themselves are pure so they can be tested without a Ghidra
135/// install, a JDK, or a mutated process environment. The binary does the IO;
136/// this decides what it means.
137#[derive(Debug, Clone, Default)]
138pub struct Probe {
139    /// Whether `<ghidra_install_dir>/support/analyzeHeadless[.bat]` exists.
140    pub launcher_present: bool,
141    /// Major version reported by `java -version`, if a `java` was reachable.
142    pub java_major: Option<u32>,
143}
144
145/// Ghidra 12.1.2 declares `application.java.min=21`; anything older fails at
146/// JVM start with a message that does not mention the version.
147pub const MIN_JAVA_MAJOR: u32 = 21;
148
149/// Report what is missing, in the order a user should fix it. An empty vector
150/// means every cheap check passed — and the hook then says nothing at all.
151///
152/// Deliberately *cheap*: no JVM spawn, no Ghidra project open, no lock probe.
153/// This runs on every session start, so it may cost milliseconds, not seconds.
154pub fn preflight(cfg: &RawConfig, probe: &Probe, host: Host) -> Vec<String> {
155    let mut findings = Vec::new();
156
157    match cfg.ghidra_install_dir.as_deref() {
158        None => findings.push(
159            "GHIDRA_INSTALL_DIR is not set. Point it at your Ghidra install root \
160             (the directory containing `support/`)."
161                .to_string(),
162        ),
163        Some(dir) if !probe.launcher_present => findings.push(format!(
164            "GHIDRA_INSTALL_DIR is set to `{dir}` but there is no \
165             `support/analyzeHeadless` there, so it is not a Ghidra install root."
166        )),
167        Some(_) => {}
168    }
169
170    match probe.java_major {
171        None => findings.push(
172            "No `java` on PATH. Ghidra 12.1.2 requires JDK 21 \
173             (`application.java.min=21`)."
174                .to_string(),
175        ),
176        Some(v) if v < MIN_JAVA_MAJOR => findings.push(format!(
177            "`java` on PATH reports version {v}; Ghidra 12.1.2 requires \
178             JDK {MIN_JAVA_MAJOR} or newer."
179        )),
180        Some(_) => {}
181    }
182
183    // The three per-project values. Reported as one finding rather than three:
184    // they are always configured together, in the same place.
185    let missing: Vec<&str> = [
186        ("project_dir", cfg.project_dir.is_none()),
187        ("project_name", cfg.project_name.is_none()),
188        ("bootstrap_program", cfg.bootstrap_program.is_none()),
189    ]
190    .into_iter()
191    .filter_map(|(name, absent)| absent.then_some(name))
192    .collect();
193    if !missing.is_empty() {
194        findings.push(format!(
195            "Unset: {}. Set them in `{}` (see the plugin's examples/ directory) or as \
196             GHIDRA_MCP_PROJECT_DIR / GHIDRA_MCP_PROJECT_NAME / \
197             GHIDRA_MCP_BOOTSTRAP_PROGRAM.",
198            missing.join(", "),
199            host.settings_rel_path()
200        ));
201    }
202
203    findings
204}
205
206/// Parse the major version out of `java -version` output.
207///
208/// The output goes to **stderr**, and the two shapes in the wild are
209/// `"21.0.2"` (modern) and `"1.8.0_412"` (legacy, where the major is the
210/// second component). Returning `None` for anything unrecognized keeps a
211/// surprising vendor string from being read as a version far too low.
212pub fn parse_java_major(version_output: &str) -> Option<u32> {
213    let quoted = version_output.split('"').nth(1)?;
214    let mut parts = quoted.split(['.', '_', '-']);
215    let first: u32 = parts.next()?.parse().ok()?;
216    if first == 1 {
217        parts.next()?.parse().ok()
218    } else {
219        Some(first)
220    }
221}
222
223#[cfg(test)]
224mod preflight_tests {
225    use super::*;
226
227    fn complete() -> RawConfig {
228        RawConfig {
229            ghidra_install_dir: Some("C:/ghidra".into()),
230            project_dir: Some("C:/re".into()),
231            project_name: Some("p".into()),
232            bootstrap_program: Some("add.exe".into()),
233            ..Default::default()
234        }
235    }
236
237    fn good_probe() -> Probe {
238        Probe {
239            launcher_present: true,
240            java_major: Some(21),
241        }
242    }
243
244    /// The silent path. If this ever starts reporting something, the hook goes
245    /// from invisible to noisy on every single session.
246    #[test]
247    fn fully_configured_reports_nothing() {
248        assert!(preflight(&complete(), &good_probe(), Host::Claude).is_empty());
249    }
250
251    #[test]
252    fn install_dir_set_but_not_a_ghidra_root_is_reported() {
253        let probe = Probe {
254            launcher_present: false,
255            ..good_probe()
256        };
257        let f = preflight(&complete(), &probe, Host::Claude);
258        assert_eq!(f.len(), 1);
259        assert!(f[0].contains("analyzeHeadless"), "{}", f[0]);
260    }
261
262    #[test]
263    fn guidance_names_the_active_hosts_file() {
264        let cfg = RawConfig {
265            project_dir: None,
266            project_name: None,
267            bootstrap_program: None,
268            ..complete()
269        };
270        let f = preflight(&cfg, &good_probe(), Host::Opencode);
271        assert_eq!(f.len(), 1);
272        assert!(
273            f[0].contains(".opencode/re-ghidra-mcp-opencode.local.md"),
274            "{}",
275            f[0]
276        );
277        let f = preflight(&cfg, &good_probe(), Host::Claude);
278        assert_eq!(f.len(), 1);
279        assert!(
280            f[0].contains(".claude/re-ghidra-mcp-cc.local.md"),
281            "{}",
282            f[0]
283        );
284    }
285
286    #[test]
287    fn old_java_is_reported_with_its_version() {
288        let probe = Probe {
289            java_major: Some(17),
290            ..good_probe()
291        };
292        let f = preflight(&complete(), &probe, Host::Claude);
293        assert_eq!(f.len(), 1);
294        assert!(f[0].contains("17") && f[0].contains("21"), "{}", f[0]);
295    }
296
297    #[test]
298    fn absent_java_is_reported() {
299        let probe = Probe {
300            java_major: None,
301            ..good_probe()
302        };
303        assert!(preflight(&complete(), &probe, Host::Claude)[0].contains("No `java` on PATH"));
304    }
305
306    /// The three per-project values collapse into one finding, naming exactly
307    /// the ones that are missing.
308    #[test]
309    fn missing_project_values_are_one_finding_naming_each() {
310        let cfg = RawConfig {
311            project_name: None,
312            bootstrap_program: None,
313            ..complete()
314        };
315        let f = preflight(&cfg, &good_probe(), Host::Claude);
316        assert_eq!(f.len(), 1);
317        assert!(f[0].contains("project_name"), "{}", f[0]);
318        assert!(f[0].contains("bootstrap_program"), "{}", f[0]);
319        assert!(!f[0].contains("project_dir,"), "{}", f[0]);
320    }
321
322    #[test]
323    fn parses_modern_and_legacy_java_version_strings() {
324        assert_eq!(
325            parse_java_major("openjdk version \"21.0.2\" 2024-01-16"),
326            Some(21)
327        );
328        assert_eq!(
329            parse_java_major("java version \"1.8.0_412\"\nJava(TM) SE Runtime"),
330            Some(8)
331        );
332        assert_eq!(
333            parse_java_major("openjdk version \"25\" 2025-09-16"),
334            Some(25)
335        );
336    }
337
338    /// Unparseable output must read as "unknown", never as a low version — the
339    /// latter would produce a confidently wrong "upgrade your JDK" message.
340    #[test]
341    fn unrecognized_version_output_is_none() {
342        assert_eq!(parse_java_major("command not found"), None);
343        assert_eq!(parse_java_major("openjdk version \"weird\""), None);
344        assert_eq!(parse_java_major(""), None);
345    }
346}
347
348#[cfg(test)]
349mod settings_tests {
350    use super::*;
351
352    const SAMPLE: &str = "---\nproject_dir: C:\\re\\projects\nproject_name: crackme\nbootstrap_program: add.exe\n---\n\n# notes\n";
353
354    #[test]
355    fn reads_the_frontmatter_keys() {
356        let c = settings_from_markdown(SAMPLE);
357        assert_eq!(c.project_dir.as_deref(), Some("C:\\re\\projects"));
358        assert_eq!(c.project_name.as_deref(), Some("crackme"));
359        assert_eq!(c.bootstrap_program.as_deref(), Some("add.exe"));
360        assert_eq!(c.max_heap, None);
361    }
362
363    #[test]
364    fn crlf_and_bom_still_parse() {
365        let crlf = format!("\u{feff}{}", SAMPLE.replace('\n', "\r\n"));
366        let c = settings_from_markdown(&crlf);
367        assert_eq!(c.project_name.as_deref(), Some("crackme"));
368    }
369
370    /// A file with no frontmatter, or an unterminated block, contributes
371    /// nothing rather than half-parsing into a confusing partial config.
372    #[test]
373    fn no_frontmatter_yields_nothing() {
374        assert!(settings_from_markdown("# just a heading\n")
375            .project_name
376            .is_none());
377        assert!(settings_from_markdown("---\nproject_name: x\n")
378            .project_name
379            .is_none());
380    }
381
382    /// A key present but blank must not resolve to an empty project name — that
383    /// would pass the "is it set?" check and then fail deep inside Ghidra.
384    #[test]
385    fn blank_value_is_not_a_value() {
386        assert!(settings_from_markdown("---\nproject_name:   \n---\n")
387            .project_name
388            .is_none());
389    }
390
391    #[test]
392    fn unknown_keys_are_ignored() {
393        let c = settings_from_markdown("---\nfuture_key: 1\nproject_name: p\n---\n");
394        assert_eq!(c.project_name.as_deref(), Some("p"));
395    }
396
397    #[test]
398    fn settings_path_is_per_host() {
399        let dir = Path::new("/proj");
400        assert!(settings_path(dir, Host::Claude).ends_with(".claude/re-ghidra-mcp-cc.local.md"));
401        assert!(settings_path(dir, Host::Opencode)
402            .ends_with(".opencode/re-ghidra-mcp-opencode.local.md"));
403    }
404
405    #[test]
406    fn host_from_str_defaults_to_claude() {
407        assert_eq!(host_from_str(None), Host::Claude);
408        assert_eq!(host_from_str(Some("")), Host::Claude);
409        assert_eq!(host_from_str(Some("claude")), Host::Claude);
410        assert_eq!(host_from_str(Some("OPENCODE")), Host::Claude); // exact match only
411        assert_eq!(host_from_str(Some("opencode")), Host::Opencode);
412    }
413}