Skip to main content

ghidra_mcp/
cli.rs

1//! The argv-level entry point every agent's plugin binary dispatches into.
2//!
3//! The plugin binaries (`re-ghidra-cc-mcp` today; the agy and qwen ports later)
4//! own nothing but a `main` that hands its argv here. Flag parsing, config
5//! precedence, log initialization and the serve loop all live in this crate, so
6//! three front ends cannot drift into three subtly different config contracts.
7//!
8//! `product` is the invoked binary's own name. It appears only in the usage
9//! banner and error prefixes — never in an env var or a path — so each agent's
10//! diagnostics name the binary the user actually ran.
11
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::time::Duration;
15
16/// Dispatch `args` (everything after argv[0]) and return the process exit code.
17///
18/// `file_config` is the agent's own settings-file layer, sitting below the
19/// environment in precedence. Each agent stores settings differently — Claude
20/// Code uses `.claude/<plugin>.local.md`, Qwen and agy will use their own — so
21/// the *format* is the plugin's business while the *precedence* is decided once,
22/// here. Pass `RawConfig::default()` for an agent with no settings file.
23///
24/// Exit codes are part of the contract with the launching agent: 0 success,
25/// 1 serve terminated with an error, 2 bad usage, 3 the log file could not be
26/// opened.
27///
28/// A bad or absent Ghidra CONFIGURATION is deliberately not among them: `serve`
29/// starts anyway and reports the problem on the first tool call, so that a host
30/// listing the tool schemas gets them on a machine with no Ghidra install.
31pub async fn dispatch(
32    product: &str,
33    version: &str,
34    args: Vec<String>,
35    file_config: crate::config::RawConfig,
36) -> i32 {
37    let mut it = args.into_iter();
38    match it.next().as_deref() {
39        Some("serve") => match run_serve(product, it.collect(), file_config).await {
40            Ok(()) => 0,
41            Err(code) => code,
42        },
43        Some("emit-skill") => {
44            // stdout is safe here: `emit-skill` is a one-shot maintainer utility,
45            // NOT the `serve` MCP JSON-RPC channel. `print!` (no trailing newline)
46            // keeps the output byte-identical to the committed plugin copy, so
47            // `<bin> emit-skill > SKILL.md` round-trips exactly. Pinned by the
48            // plugin's tests/skill_emit.rs.
49            print!("{}", crate::skill_asset::emit_bytes());
50            0
51        }
52        Some("boot-smoke") => {
53            tracing_subscriber::fmt()
54                .with_writer(std::io::stderr)
55                .init();
56            boot_smoke().await;
57            0
58        }
59        Some("--version" | "-V" | "--help" | "-h") => {
60            usage(product, version);
61            0
62        }
63        _ => {
64            usage(product, version);
65            2
66        }
67    }
68}
69
70/// Version + usage, on stderr. Never stdout: an agent that launched this binary
71/// as an MCP server is reading stdout as JSON-RPC, and a usage banner there
72/// looks like a protocol violation rather than a mistyped command.
73fn usage(product: &str, version: &str) {
74    eprintln!("{product} {version}");
75    eprintln!(
76        "usage: {product} serve        (env: GHIDRA_INSTALL_DIR, GHIDRA_MCP_PROJECT_DIR, \
77         GHIDRA_MCP_PROJECT_NAME, GHIDRA_MCP_BOOTSTRAP_PROGRAM)"
78    );
79    eprintln!("       {product} emit-skill  (writes the embedded driver skill to stdout)");
80    eprintln!("       {product} boot-smoke  (dev; env: GHIDRA_INSTALL_DIR, GHIDRA_MCP_FIXTURE_*)");
81}
82
83/// Layer config (CLI > env > settings file), initialize file logging, and serve.
84/// `Err(code)` carries the exit code; the reason is reported on STDERR, never
85/// stdout — stdout is the MCP channel.
86async fn run_serve(
87    product: &str,
88    cli: Vec<String>,
89    file_config: crate::config::RawConfig,
90) -> Result<(), i32> {
91    // NOT resolved here. A bad or absent Ghidra configuration used to exit 2 before `serve` was ever
92    // reached, which meant a tool-schema probe got nothing on a machine without Ghidra. The layered
93    // inputs are handed to `serve` unresolved; resolution still happens once, inside `ServerState`,
94    // and its failure surfaces on the first tool call.
95    let raw = layered_config(cli, file_config);
96    let log_path = crate::paths::instance_log_path();
97    let _guard = match crate::logging::init_file_logging(&log_path) {
98        Ok(g) => g,
99        Err(e) => {
100            eprintln!(
101                "{product} serve: cannot initialize log at {}: {e}",
102                log_path.display()
103            );
104            return Err(3);
105        }
106    };
107    tracing::info!(?log_path, product, "serve starting");
108    if let Err(e) = crate::server::serve(raw).await {
109        tracing::error!("serve terminated: {e}");
110        return Err(1);
111    }
112    Ok(())
113}
114
115/// Layer CLI flags over the environment over the agent's settings file.
116///
117/// Returns the merged-but-UNRESOLVED inputs. Validation is deliberately not done
118/// here: `serve` must start even when the result cannot resolve, so resolution
119/// happens once inside `ServerState` and its failure is reported on the first
120/// tool call rather than at startup.
121///
122/// A CLI flag that was silently ignored would be a config trap, so flags DO
123/// layer over the environment rather than being an either/or — and the settings
124/// file sits below both, so a user can override a committed project setting for
125/// one session without editing it.
126pub fn layered_config(
127    cli: Vec<String>,
128    file_config: crate::config::RawConfig,
129) -> crate::config::RawConfig {
130    layer(parse_flags(&cli), file_config, env_or_none)
131}
132
133/// The precedence rule itself, with the environment injected so it is testable
134/// without mutating the process environment (which races under a parallel test
135/// runner).
136fn layer<F>(
137    flags: HashMap<String, String>,
138    file: crate::config::RawConfig,
139    get_env: F,
140) -> crate::config::RawConfig
141where
142    F: Fn(&str) -> Option<String>,
143{
144    // The empty-string filter lives HERE rather than in the caller's getter, so
145    // the "an empty variable does not shadow the file" property holds for every
146    // caller instead of depending on each one remembering to filter.
147    let pick = |flag: &str, env: &str, from_file: Option<String>| {
148        flags
149            .get(flag)
150            .cloned()
151            .or_else(|| get_env(env).filter(|s| !s.is_empty()))
152            .or(from_file)
153    };
154    crate::config::RawConfig {
155        ghidra_install_dir: pick(
156            "ghidra-install-dir",
157            "GHIDRA_INSTALL_DIR",
158            file.ghidra_install_dir,
159        ),
160        project_dir: pick("project-dir", "GHIDRA_MCP_PROJECT_DIR", file.project_dir),
161        project_name: pick("project-name", "GHIDRA_MCP_PROJECT_NAME", file.project_name),
162        bootstrap_program: pick(
163            "bootstrap-program",
164            "GHIDRA_MCP_BOOTSTRAP_PROGRAM",
165            file.bootstrap_program,
166        ),
167        bootstrap_program_path: pick(
168            "bootstrap-program-path",
169            "GHIDRA_MCP_BOOTSTRAP_PROGRAM_PATH",
170            file.bootstrap_program_path,
171        ),
172        max_heap: pick("max-heap", "GHIDRA_MCP_MAX_HEAP", file.max_heap),
173    }
174}
175
176fn env_or_none(name: &str) -> Option<String> {
177    std::env::var(name).ok().filter(|s| !s.is_empty())
178}
179
180/// Parse `--key value` and `--key=value` into a map. Non-flag tokens are
181/// skipped; unrecognized flags are collected rather than rejected, so they
182/// simply match no `pick(..)` above (forward compatibility).
183fn parse_flags(args: &[String]) -> HashMap<String, String> {
184    let mut map = HashMap::new();
185    let mut i = 0;
186    while i < args.len() {
187        if let Some(rest) = args[i].strip_prefix("--") {
188            if let Some((k, v)) = rest.split_once('=') {
189                map.insert(k.to_string(), v.to_string());
190                i += 1;
191            } else if i + 1 < args.len() {
192                map.insert(rest.to_string(), args[i + 1].clone());
193                i += 2;
194            } else {
195                i += 1;
196            }
197        } else {
198            i += 1;
199        }
200    }
201    map
202}
203
204/// Dev-only: boot the worker and drive attach + decompile end to end, logging
205/// to stderr. Needs a real Ghidra install and the `GHIDRA_MCP_FIXTURE_*` vars.
206async fn boot_smoke() {
207    let script_dir = std::env::temp_dir().join("ghidra-mcp-smoke-scripts");
208    if let Err(e) = crate::worker_asset_extract(&script_dir) {
209        eprintln!(
210            "boot-smoke: cannot extract worker script to {}: {e}",
211            script_dir.display()
212        );
213        std::process::exit(1);
214    }
215    let cfg = ghidra_worker_ctl::config::WorkerConfig {
216        ghidra_install_dir: PathBuf::from(env("GHIDRA_INSTALL_DIR")),
217        project_dir: PathBuf::from(env("GHIDRA_MCP_FIXTURE_PROJECT_DIR")),
218        project_name: env("GHIDRA_MCP_FIXTURE_PROJECT_NAME"),
219        bootstrap_program: env("GHIDRA_MCP_FIXTURE_PROGRAM"),
220        script_dir,
221        script_name: crate::worker_asset::WORKER_SCRIPT_NAME.to_string(),
222        max_heap: None,
223        boot_timeout: Duration::from_secs(120),
224    };
225    let mut w = match ghidra_worker_ctl::boot::boot_worker(&cfg).await {
226        Ok(w) => w,
227        Err(e) => {
228            eprintln!("boot-smoke: worker failed to boot: {e}");
229            eprintln!("(if it hangs on Windows, check EDR/firewall on the loopback bind)");
230            std::process::exit(1);
231        }
232    };
233    eprintln!("booted worker: {:?}", w.announce);
234    let program = env("GHIDRA_MCP_FIXTURE_PROGRAM");
235    let func = env("GHIDRA_MCP_FIXTURE_FUNCTION");
236    let attach = w
237        .conn
238        .request(&mk(
239            1,
240            "attach_program",
241            serde_json::json!({ "program_path": program }),
242        ))
243        .await
244        .unwrap();
245    eprintln!("attach: {attach:?}");
246    let dec = w
247        .conn
248        .request(&mk(
249            2,
250            "decompile_function",
251            serde_json::json!({ "name": func }),
252        ))
253        .await
254        .unwrap();
255    eprintln!("decompile: {dec:?}");
256    let _ = w.conn.send(&mk(3, "shutdown", serde_json::json!({}))).await;
257}
258
259fn env(name: &str) -> String {
260    // Dev-only helper, but still a shipped binary: report + exit(2) so a missing
261    // fixture var reads as bad usage, not a panic backtrace.
262    std::env::var(name).unwrap_or_else(|_| {
263        eprintln!("boot-smoke: missing env var {name}");
264        std::process::exit(2);
265    })
266}
267
268fn mk(id: i64, method: &str, params: serde_json::Value) -> ghidra_ipc::rpc::RpcRequest {
269    ghidra_ipc::rpc::RpcRequest {
270        jsonrpc: ghidra_ipc::rpc::JsonRpcVersion,
271        id: ghidra_ipc::rpc::RpcId::Number(id),
272        method: method.to_string(),
273        params: Some(params),
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    /// Bad usage is exit 2, as the `dispatch` contract says.
282    #[tokio::test]
283    async fn unknown_command_exits_2() {
284        let code = dispatch(
285            "p",
286            "0",
287            vec!["bogus".into()],
288            crate::config::RawConfig::default(),
289        )
290        .await;
291        assert_eq!(code, 2);
292        let code = dispatch("p", "0", vec![], crate::config::RawConfig::default()).await;
293        assert_eq!(code, 2);
294    }
295
296    /// Asking for the version or help is not bad usage: exit 0, same output.
297    #[tokio::test]
298    async fn version_and_help_flags_exit_0() {
299        for flag in ["--version", "-V", "--help", "-h"] {
300            let code = dispatch(
301                "p",
302                "0",
303                vec![flag.into()],
304                crate::config::RawConfig::default(),
305            )
306            .await;
307            assert_eq!(code, 0, "{flag}");
308        }
309    }
310
311    #[test]
312    fn parses_both_flag_spellings() {
313        let f = parse_flags(&[
314            "--project-name".into(),
315            "proj".into(),
316            "--max-heap=4G".into(),
317        ]);
318        assert_eq!(f.get("project-name").map(String::as_str), Some("proj"));
319        assert_eq!(f.get("max-heap").map(String::as_str), Some("4G"));
320    }
321
322    /// A trailing `--flag` with no value must not consume past the end or panic.
323    #[test]
324    fn dangling_flag_is_dropped_not_panicked() {
325        let f = parse_flags(&["--project-name".into()]);
326        assert!(f.is_empty());
327    }
328
329    /// Non-flag tokens are skipped rather than mistaken for a value.
330    #[test]
331    fn positional_tokens_are_ignored() {
332        let f = parse_flags(&["stray".into(), "--max-heap=2G".into()]);
333        assert_eq!(f.len(), 1);
334        assert_eq!(f.get("max-heap").map(String::as_str), Some("2G"));
335    }
336
337    fn file_layer() -> crate::config::RawConfig {
338        crate::config::RawConfig {
339            project_name: Some("from-file".into()),
340            project_dir: Some("dir-from-file".into()),
341            ..Default::default()
342        }
343    }
344
345    /// The whole point of the three layers: a flag beats the environment, which
346    /// beats the settings file. Getting this backwards would make a committed
347    /// project setting un-overridable for a single session.
348    #[test]
349    fn flag_beats_env_beats_file() {
350        let flags = parse_flags(&["--project-name".into(), "from-flag".into()]);
351        let env = |k: &str| match k {
352            "GHIDRA_MCP_PROJECT_NAME" => Some("from-env".to_string()),
353            "GHIDRA_MCP_PROJECT_DIR" => Some("dir-from-env".to_string()),
354            _ => None,
355        };
356        let merged = layer(flags, file_layer(), env);
357        assert_eq!(merged.project_name.as_deref(), Some("from-flag"));
358        assert_eq!(merged.project_dir.as_deref(), Some("dir-from-env"));
359    }
360
361    /// With no flag and no environment, the settings file is what supplies the
362    /// value — this is the path that lets a project be configured without a
363    /// hand-written per-repo MCP registration.
364    #[test]
365    fn file_layer_supplies_values_nothing_else_set() {
366        let merged = layer(HashMap::new(), file_layer(), |_| None);
367        assert_eq!(merged.project_name.as_deref(), Some("from-file"));
368        assert_eq!(merged.project_dir.as_deref(), Some("dir-from-file"));
369        assert_eq!(merged.bootstrap_program, None);
370    }
371
372    /// An empty environment variable must not shadow the settings file. Windows
373    /// launchers routinely pass `VAR=` for an unset value, and treating that as
374    /// "set to empty" would silently blank a working configuration.
375    #[test]
376    fn empty_env_does_not_shadow_the_file() {
377        let merged = layer(HashMap::new(), file_layer(), |k| {
378            (k == "GHIDRA_MCP_PROJECT_NAME").then(String::new)
379        });
380        assert_eq!(merged.project_name.as_deref(), Some("from-file"));
381    }
382}