Skip to main content

ghidra_mcp/
server.rs

1//! MCP stdio server (lands across Tasks 12-15 of the M1a "Crucible" plan).
2//!
3//! PINNED rmcp API (version 3.1.4) — reused by tools.rs / server.rs:
4//!   - server trait: rmcp::ServerHandler
5//!   - tool declaration: #[rmcp::tool_router] on the `impl` block (generates
6//!     `Self::tool_router()`), #[rmcp::tool(description = "...")] on each async method,
7//!     #[rmcp::tool_handler] on the `impl ServerHandler for T` block (wires
8//!     call_tool/list_tools) — the fully-qualified attribute paths still resolve: `rmcp_macros`
9//!     is re-exported at the crate root via `pub use rmcp_macros::*;` (`rmcp-3.1.4/src/lib.rs`)
10//!   - server info: rmcp::model::ServerConfig (a `#[non_exhaustive]` type alias for
11//!     `InitializeResult` as of 3.1 — no more struct-literal + `..Default::default()` from
12//!     outside the defining crate), built via the builder methods instead:
13//!     `ServerConfig::new(capabilities).with_instructions(text)`; capabilities still come from
14//!     rmcp::model::ServerCapabilities::builder().enable_tools().build()
15//!   - tool result OK: rmcp::model::CallToolResult::success(vec![ContentBlock::text(s)])
16//!   - tool result domain-err: rmcp::model::CallToolResult::error(vec![ContentBlock::text(s)])
17//!     // isError:true
18//!   - text content: rmcp::model::ContentBlock::text(String) — the flat content union was
19//!     renamed from `Content` (0.8) to `ContentBlock` (3.1); `CallToolResult::success`/`error`
20//!     take `Vec<ContentBlock>` accordingly (`rmcp-3.1.4/src/model/content.rs`,
21//!     `rmcp-3.1.4/src/model.rs` around the `CallToolResult` definition)
22//!   - structured content: rmcp::model::CallToolResult::structured(serde_json::Value)
23//!     (success, sets `structured_content` + mirrors it as text) and
24//!     `CallToolResult::structured_error(Value)` for the error case; `CallToolResult` also
25//!     exposes `into_typed::<T>()` to deserialize back
26//!   - schema-invalid params: the #[tool] macro's generated argument parsing returns
27//!     `rmcp::ErrorData::invalid_params(msg, None)` on failure, which the framework surfaces
28//!     as JSON-RPC error code -32602 (ErrorCode::INVALID_PARAMS) automatically — no manual
29//!     mapping needed
30//!   - stdio serve entry: rmcp::ServiceExt::serve(handler, rmcp::transport::stdio()).await?,
31//!     then `service.waiting().await?`
32//!
33//! Verified by compiling this crate against the real, resolved `rmcp = "3.1.4"` (workspace pin
34//! `rmcp = "3.1"`) and by reading the registry source under
35//! `rmcp-3.1.4/src/{lib.rs, model.rs, model/content.rs, handler/server/tool.rs,
36//! handler/server/router/tool.rs, handler/server/wrapper/parameters.rs}` — see
37//! `shared/ghidra-mcp/Cargo.toml`.
38//!
39//! Additional API facts nailed down while porting Tasks 15/16 from 0.8.5 to 3.1.4 (kept here so
40//! the pinned block stays the single source of truth):
41//!   - `#[tool]`-annotated methods must return a type implementing `IntoCallToolResult`. The
42//!     blanket impls cover `T: IntoContents` (Content/String/()), `CallToolResult` itself,
43//!     `rmcp::ErrorData` itself, and `Result<T, E>` where both `T` and `E` are
44//!     `IntoCallToolResult` (verified in `rmcp-3.1.4/src/handler/server/tool.rs`) — the same
45//!     shape as 0.8. A bare `-> CallToolResult` return does NOT satisfy the trait. Every tool
46//!     method here therefore returns `Result<CallToolResult, rmcp::ErrorData>` and always yields
47//!     `Ok(..)` — domain failures are encoded as `CallToolResult::error(..)` inside the Ok, never
48//!     as the method's own `Err`.
49//!   - `#[tool_router]`/`#[tool]` attribute values are parsed by `darling`'s `FromMeta`, whose
50//!     default `from_expr` only accepts `Expr::Lit` (a literal) or `Expr::Group`; a bare
51//!     identifier/path (e.g. a `const DESC: &str` referenced as `description = DESC`) is
52//!     `Expr::Path` and is rejected at macro expansion. So `#[tool(description = "...")]` needs
53//!     the literal text inline; the `DESC_*` consts in `tools.rs` stay the single authored copy
54//!     of the wording and this file's literals are byte-for-byte copies of them (checked by the
55//!     `tool_descriptions_match_tools_rs_consts` regression test below).
56//!   - `schemars` is a DIRECT dependency of this crate (`Cargo.toml`'s
57//!     `schemars = { workspace = true }`), so the `#[derive(schemars::JsonSchema)]` on the arg
58//!     structs below resolves the bare `schemars::` path through Rust's 2021-edition extern-crate
59//!     prelude — no `use rmcp::schemars;` needed. (rmcp 3.1 still re-exports `schemars` at
60//!     `rmcp::schemars` when its own `schemars` feature is on, pulled in transitively by
61//!     `server` — `rmcp-3.1.4/src/lib.rs` — but this file no longer relies on that re-export,
62//!     matching the direct-dependency style used elsewhere in this workspace, e.g.
63//!     `claude-code/plugins/rtk-mcp-cc/src/bin/mcp.rs`.)
64//!   - `rmcp::handler::server::router::tool::ToolRouter<Self>` is the field type
65//!     `#[tool_router]` expects by default (field literally named `tool_router`) — the module
66//!     moved one level deeper than 0.8's `rmcp::handler::server::tool::ToolRouter` (now under
67//!     `router::tool`, not the bare `tool` module — confirmed via
68//!     `grep -rl "struct ToolRouter" rmcp-3.1.4/src/handler`). `#[tool_handler]`'s generated
69//!     `call_tool`/`list_tools` default to calling `Self::tool_router()` fresh rather than
70//!     reading `self.tool_router` (`rmcp-macros-3.1.4/src/tool_handler.rs`), so the stored field
71//!     itself goes unread — `#[allow(dead_code)]`'d below rather than removed, to keep the
72//!     conventional shape this workspace's other MCP servers use.
73//!   - the client-side call-tool params struct (used only by tests/protocol.rs) was renamed from
74//!     `rmcp::model::CallToolRequestParam` to `CallToolRequestParams` and marked
75//!     `#[non_exhaustive]` with new MRTR/SEP-2322 fields (`input_responses`, `request_state`);
76//!     build it with `CallToolRequestParams::new(name).with_arguments(json_object)` instead of a
77//!     struct literal (`rmcp-3.1.4/src/model.rs`, the `CallToolRequestParams` definition).
78
79use crate::state::ServerState;
80use ghidra_ipc::error::{ErrorCode, ErrorEnvelope};
81use ghidra_ipc::protocol::FunctionContext;
82use rmcp::handler::server::router::tool::ToolRouter;
83use rmcp::handler::server::wrapper::Parameters;
84use rmcp::model::{CallToolResult, ContentBlock, ServerCapabilities, ServerConfig};
85use rmcp::ServerHandler;
86use std::sync::Arc;
87
88/// The MCP server: one attached-worker state shared with the tool methods, plus the rmcp-generated
89/// tool router (spec §5).
90pub struct GhidraMcpServer {
91    pub state: Arc<ServerState>,
92    // Populated by `new()` via `Self::tool_router()`, but the `#[tool_handler]`-generated
93    // `call_tool`/`list_tools` methods call `Self::tool_router()` fresh each time rather than
94    // reading this field (confirmed in `rmcp-macros-3.1.4/src/tool_handler.rs`: the default
95    // `router` expression is the `Self::tool_router()` call, not `self.tool_router`), so nothing
96    // in this crate currently reads it back. Kept as the conventional `#[tool_router]` field
97    // (rmcp's own examples and this workspace's other MCP servers all carry one) in case a
98    // future caller needs the constructed instance directly.
99    #[allow(dead_code)]
100    tool_router: ToolRouter<Self>,
101}
102
103impl GhidraMcpServer {
104    pub fn new(state: Arc<ServerState>) -> Self {
105        Self {
106            state,
107            tool_router: Self::tool_router(),
108        }
109    }
110
111    /// Shared Err-path handling for tools that can throw AMBIGUOUS_SYMBOL with address-bearing
112    /// candidates (get_xrefs, get_disassembly): canonicalize `details.candidates[].address` using the
113    /// live canonicalizer before returning the error result. Other codes pass through unchanged.
114    async fn err_canon(&self, mut e: ghidra_ipc::error::ErrorEnvelope) -> CallToolResult {
115        if e.error.code == ghidra_ipc::error::ErrorCode::AmbiguousSymbol {
116            if let Some(d) = e.error.details.as_mut() {
117                if let Some(canon) = crate::execute::current_canon(&self.state).await {
118                    crate::tools::canonicalize_candidates(&canon, d);
119                }
120            }
121        }
122        err_result(&e)
123    }
124}
125
126/// Serve MCP over stdio until the client disconnects. Kicks off warmup right after the transport is up
127/// (spec §5) and performs graceful shutdown on exit (spec §4.6).
128pub async fn serve(raw: crate::config::RawConfig) -> std::io::Result<()> {
129    let script_dir = crate::paths::versioned_script_dir();
130    // Unresolved on purpose: a bad or absent Ghidra configuration must not stop the server coming up,
131    // so that `tools/list` still answers on a machine with no Ghidra. The failure is reported by the
132    // first tool CALL instead (state.rs / execute.rs).
133    let state = Arc::new(ServerState::from_raw(raw, script_dir));
134    if let Err(e) = state.cfg() {
135        tracing::warn!(
136            reason = %e.error.message,
137            "no usable Ghidra configuration; tools will list but every call will fail"
138        );
139    }
140    let server = GhidraMcpServer::new(Arc::clone(&state));
141    // Warmup: single-flight background boot (spec §5); sets Booting so the tool path returns
142    // WORKER_WARMING while it boots (start_warmup is the one correct kick — never bare spawn_boot).
143    state.start_warmup().await;
144    // Serve over stdio (EXACT pinned call: rmcp::ServiceExt::serve(server, rmcp::transport::stdio()).await).
145    let running = rmcp::ServiceExt::serve(server, rmcp::transport::stdio())
146        .await
147        .map_err(std::io::Error::other)?;
148    // Shut down on stdio EOF OR signal (Ctrl-C / SIGTERM) — spec §4.6.
149    tokio::select! {
150        _ = running.waiting() => { tracing::info!("stdio closed; shutting down"); }
151        _ = shutdown_signal() => { tracing::info!("signal received; shutting down"); }
152    }
153    graceful_shutdown(&state).await;
154    Ok(())
155}
156
157async fn shutdown_signal() {
158    #[cfg(unix)]
159    {
160        use tokio::signal::unix::{signal, SignalKind};
161        let mut term = match signal(SignalKind::terminate()) {
162            Ok(s) => s,
163            Err(_) => {
164                let _ = tokio::signal::ctrl_c().await;
165                return;
166            }
167        };
168        tokio::select! {
169            _ = tokio::signal::ctrl_c() => {}
170            _ = term.recv() => {}
171        }
172    }
173    #[cfg(not(unix))]
174    {
175        let _ = tokio::signal::ctrl_c().await;
176    }
177}
178
179/// Best-effort orderly worker release: a short `shutdown` RPC, WAIT bounded for exit, then drop
180/// (JobObject kills the tree). `WorkerProcess.child` is a std::process::Child (blocking wait) so this
181/// uses a bounded try_wait poll (R6 seat-Q) — a one-shot at exit, not a hot loop.
182pub async fn graceful_shutdown(state: &Arc<ServerState>) {
183    use crate::state::mk;
184    let mut slot = state.worker.lock().await;
185    if let crate::state::WorkerSlot::Ready(h) = &mut *slot {
186        let _ = h
187            .booted
188            .conn
189            .send(&mk("shutdown", serde_json::json!({})))
190            .await;
191        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
192        loop {
193            match h.booted.process.child.try_wait() {
194                Ok(Some(_status)) => break,
195                Ok(None) if std::time::Instant::now() < deadline => {
196                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
197                }
198                _ => break,
199            }
200        }
201    }
202}
203
204// Params structs derive rmcp's schema (JsonSchema + Deserialize) so schema-invalid input yields -32602
205// BEFORE the handler runs (spec §4.2 protocol layer). Field types ARE the first defense.
206#[derive(serde::Deserialize, schemars::JsonSchema)]
207pub struct ListProgramsArgs {
208    #[serde(default)]
209    pub offset: u64,
210    #[serde(default = "default_limit")]
211    pub limit: u64,
212}
213fn default_limit() -> u64 {
214    200
215}
216
217#[derive(serde::Deserialize, schemars::JsonSchema)]
218pub struct AttachArgs {
219    pub program_path: String,
220}
221
222#[derive(serde::Deserialize, schemars::JsonSchema)]
223pub struct InspectArgs {
224    pub function: String,
225    #[serde(default = "default_max_c_lines")]
226    pub max_c_lines: u64,
227}
228fn default_max_c_lines() -> u64 {
229    2000
230}
231
232#[derive(serde::Deserialize, schemars::JsonSchema)]
233pub struct FindFunctionsArgs {
234    #[serde(default)]
235    pub filter: Option<String>,
236    #[serde(default)]
237    pub regex: bool,
238    #[serde(default)]
239    pub offset: u64,
240    #[serde(default = "default_find_limit")]
241    pub limit: u64,
242}
243fn default_find_limit() -> u64 {
244    100
245}
246
247#[derive(serde::Deserialize, schemars::JsonSchema)]
248pub struct ResolveSymbolArgs {
249    pub name: String,
250}
251
252#[derive(serde::Deserialize, schemars::JsonSchema)]
253pub struct ListSymbolsArgs {
254    #[serde(default)]
255    pub kind: Option<String>,
256    #[serde(default)]
257    pub filter: Option<String>,
258    #[serde(default)]
259    pub regex: bool,
260    #[serde(default)]
261    pub offset: u64,
262    #[serde(default = "default_find_limit")]
263    pub limit: u64,
264}
265
266#[derive(serde::Deserialize, schemars::JsonSchema)]
267pub struct ListStringsArgs {
268    #[serde(default)]
269    pub filter: Option<String>,
270    #[serde(default)]
271    pub regex: bool,
272    #[serde(default)]
273    pub offset: u64,
274    #[serde(default = "default_list_strings_limit")]
275    pub limit: u64,
276}
277fn default_list_strings_limit() -> u64 {
278    200
279}
280
281#[derive(serde::Deserialize, schemars::JsonSchema)]
282pub struct GetXrefsArgs {
283    pub target: String,
284    #[serde(default = "default_direction")]
285    pub direction: String,
286    #[serde(default)]
287    pub offset: u64,
288    #[serde(default = "default_find_limit")]
289    pub limit: u64,
290}
291fn default_direction() -> String {
292    "both".to_string()
293}
294
295#[derive(serde::Deserialize, schemars::JsonSchema)]
296pub struct GetDisassemblyArgs {
297    pub target: String,
298    #[serde(default)]
299    pub offset: u64,
300    #[serde(default = "default_list_strings_limit")]
301    pub limit: u64,
302}
303
304#[derive(serde::Deserialize, schemars::JsonSchema)]
305pub struct ReadBytesArgs {
306    pub address: String,
307    #[serde(default)]
308    pub offset: i64,
309    #[serde(default = "default_read_bytes_length")]
310    pub length: u64,
311}
312fn default_read_bytes_length() -> u64 {
313    256
314}
315
316#[derive(serde::Deserialize, schemars::JsonSchema)]
317pub struct GetDatatypeArgs {
318    pub name: String,
319}
320
321#[derive(serde::Deserialize, schemars::JsonSchema)]
322pub struct ListSegmentsArgs {}
323
324#[derive(serde::Deserialize, schemars::JsonSchema)]
325pub struct ListDataItemsArgs {
326    #[serde(default)]
327    pub filter: Option<String>,
328    #[serde(default)]
329    pub regex: bool,
330    #[serde(default)]
331    pub offset: u64,
332    #[serde(default = "default_list_strings_limit")]
333    pub limit: u64,
334}
335
336#[derive(serde::Deserialize, schemars::JsonSchema)]
337pub struct DescribeAddressArgs {
338    pub address: String,
339}
340
341#[derive(serde::Deserialize, schemars::JsonSchema)]
342pub struct RenameArgs {
343    pub target: String,
344    pub expected_name: String,
345    pub new_name: String,
346}
347
348/// Wire enum for the `comment` channel. schemars emits a fixed `enum` in the JSON schema, so a bad value
349/// is rejected with -32602 before it reaches the worker. serde `lowercase` matches the worker's strings.
350#[derive(serde::Deserialize, serde::Serialize, schemars::JsonSchema, Clone, Copy)]
351#[serde(rename_all = "lowercase")]
352pub enum CommentChannel {
353    Eol,
354    Pre,
355    Post,
356    Plate,
357    Repeatable,
358}
359impl CommentChannel {
360    fn as_str(self) -> &'static str {
361        match self {
362            CommentChannel::Eol => "eol",
363            CommentChannel::Pre => "pre",
364            CommentChannel::Post => "post",
365            CommentChannel::Plate => "plate",
366            CommentChannel::Repeatable => "repeatable",
367        }
368    }
369}
370
371#[derive(serde::Deserialize, schemars::JsonSchema)]
372pub struct CommentArgs {
373    pub target: String,
374    pub comment_type: CommentChannel,
375    pub new_comment: String,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub expected_comment: Option<String>,
378}
379
380#[derive(serde::Deserialize, schemars::JsonSchema)]
381pub struct SetDatatypeArgs {
382    pub target: String,
383    pub datatype_name: String,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub expected_datatype: Option<String>,
386}
387
388/// One parameter of a `set_prototype` request. `name` is optional — omit (or send "") to auto-name
389/// (`param_1…`); the worker coerces "" → null before `ParameterImpl`. `type_` serializes as `type`.
390#[derive(serde::Deserialize, schemars::JsonSchema)]
391pub struct ParamArg {
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub name: Option<String>,
394    #[serde(rename = "type")]
395    pub type_: String,
396}
397
398#[derive(serde::Deserialize, schemars::JsonSchema)]
399pub struct SetPrototypeArgs {
400    pub target: String,
401    pub return_type: String,
402    pub calling_convention: String,
403    /// Ordered parameter list; empty `[]` = no arguments.
404    pub params: Vec<ParamArg>,
405    #[serde(default)]
406    pub variadic: bool,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub expected_signature: Option<String>,
409}
410
411#[derive(serde::Deserialize, schemars::JsonSchema)]
412pub struct SetLocalArgs {
413    pub target: String,
414    /// Storage handle ("stack:-0x18") — preferred — or the local's current name.
415    pub variable: String,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub new_type: Option<String>,
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub new_name: Option<String>,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub expected_type: Option<String>,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub expected_name: Option<String>,
424}
425
426/// Turn an `ErrorEnvelope` into an isError CallToolResult (spec §4.2 domain layer).
427fn err_result(env: &ErrorEnvelope) -> CallToolResult {
428    let text = serde_json::to_string(env).unwrap_or_else(|_| "{\"error\":\"serialize\"}".into());
429    CallToolResult::error(vec![ContentBlock::text(text)])
430}
431
432/// OK result carrying a JSON value as text.
433fn ok_result(v: &serde_json::Value) -> CallToolResult {
434    let text = serde_json::to_string(v).unwrap_or_else(|_| "null".into());
435    CallToolResult::success(vec![ContentBlock::text(text)])
436}
437
438#[rmcp::tool_router]
439impl GhidraMcpServer {
440    #[rmcp::tool(
441        description = "List the programs (binaries) in the open Ghidra project as VFS paths (e.g. \"/add.exe\"). Use this first to discover a program_path to pass to attach_program. Results are paginated: pass offset and limit (default 200); the reply's `total` and `truncated` tell you if more remain. Read-only; no program needs to be attached."
442    )]
443    async fn list_project_programs(
444        &self,
445        Parameters(args): Parameters<ListProgramsArgs>,
446    ) -> Result<CallToolResult, rmcp::ErrorData> {
447        let params = serde_json::json!({ "offset": args.offset, "limit": args.limit });
448        match crate::execute::call_worker(
449            &self.state,
450            "list_project_programs",
451            "",
452            "list_project_programs",
453            params,
454        )
455        .await
456        {
457            Ok((v, _canon)) => Ok(ok_result(&v)),
458            Err(e) => Ok(err_result(&e)),
459        }
460    }
461
462    #[rmcp::tool(
463        description = "Attach the worker to a program so inspect_function targets it. `program_path` is the absolute VFS path from list_project_programs (must begin with '/', e.g. \"/add.exe\"). One program is attached at a time; attaching switches. On boot a bootstrap program is already attached, so you can inspect_function immediately without calling this first."
464    )]
465    async fn attach_program(
466        &self,
467        Parameters(args): Parameters<AttachArgs>,
468    ) -> Result<CallToolResult, rmcp::ErrorData> {
469        match crate::execute::attach_program(&self.state, &args.program_path).await {
470            Ok(v) => Ok(ok_result(&v)),
471            Err(e) => Ok(err_result(&e)),
472        }
473    }
474
475    #[rmcp::tool(
476        description = "Decompile and describe one function in the attached program. `function` is EITHER a symbol name (e.g. \"main\") OR a canonical address. Addresses use the form space:offset in hex — the default code space renders as a bare 0x value like \"0x00401000\", and other spaces keep their prefix like \"EXTERNAL:0x00000001\". If a name matches multiple functions you get an AMBIGUOUS_SYMBOL error listing each candidate's address — re-call with the disambiguating address. The reply includes the decompiled C (capped from the top by max_c_lines, default 2000; c_truncated=true means the body was longer — raise max_c_lines to see more), the signature, calling convention, thunk info, and callers/callees. A caller or callee with external=true is an imported library function with NO body — identify it by name; do not pass it back to inspect_function. To reach a function you can't name, navigate callers/callees from one you can (e.g. an entry point or export)."
477    )]
478    async fn inspect_function(
479        &self,
480        Parameters(args): Parameters<InspectArgs>,
481    ) -> Result<CallToolResult, rmcp::ErrorData> {
482        let params =
483            serde_json::json!({ "function": args.function, "max_c_lines": args.max_c_lines });
484        match crate::execute::call_worker(
485            &self.state,
486            "inspect_function",
487            &args.function,
488            "inspect_function",
489            params,
490        )
491        .await
492        {
493            Ok((v, canon)) => match serde_json::from_value::<FunctionContext>(v) {
494                Ok(fc) => {
495                    let fc = crate::tools::canonicalize_function_context(&canon, fc);
496                    let v = serde_json::to_value(&fc).unwrap_or(serde_json::Value::Null);
497                    Ok(ok_result(&v))
498                }
499                Err(e) => Ok(err_result(&ErrorEnvelope::new(
500                    ErrorCode::ParseError,
501                    format!("inspect_function result shape: {e}"),
502                ))),
503            },
504            Err(e) => Ok(self.err_canon(e).await),
505        }
506    }
507
508    #[rmcp::tool(
509        description = "List defined functions in the attached program, optionally narrowed by a `filter` matched against the function name IN the worker (so only matches cross the wire). The filter is a substring match by default; pass regex:true for a regular-expression match (linear-time, length-capped). Returns cheap metadata per hit (name, canonical entry_point, signature, calling_convention, is_thunk, size, external) — NOT the decompiled C or call edges; use inspect_function for those. Paginated: pass offset and limit (default 100); page until has_more is false."
510    )]
511    async fn find_functions(
512        &self,
513        Parameters(args): Parameters<FindFunctionsArgs>,
514    ) -> Result<CallToolResult, rmcp::ErrorData> {
515        let params = serde_json::json!({
516            "filter": args.filter,
517            "regex": args.regex,
518            "offset": args.offset,
519            "limit": args.limit,
520        });
521        match crate::execute::call_worker(
522            &self.state,
523            "find_functions",
524            "",
525            "find_functions",
526            params,
527        )
528        .await
529        {
530            Ok((mut v, canon)) => {
531                crate::tools::canonicalize_functions(&canon, &mut v);
532                Ok(ok_result(&v))
533            }
534            Err(e) => Ok(err_result(&e)),
535        }
536    }
537
538    #[rmcp::tool(
539        description = "Resolve a symbol NAME to its address(es); enumerates every match; 0 → SYMBOL_NOT_FOUND; call this after an AMBIGUOUS_SYMBOL from inspect_function."
540    )]
541    async fn resolve_symbol(
542        &self,
543        Parameters(args): Parameters<ResolveSymbolArgs>,
544    ) -> Result<CallToolResult, rmcp::ErrorData> {
545        let params = serde_json::json!({ "name": args.name });
546        match crate::execute::call_worker(
547            &self.state,
548            "resolve_symbol",
549            &args.name,
550            "resolve_symbol",
551            params,
552        )
553        .await
554        {
555            Ok((mut v, canon)) => {
556                crate::tools::canonicalize_addr_array(&canon, &mut v, "candidates", "address");
557                Ok(ok_result(&v))
558            }
559            Err(e) => Ok(err_result(&e)),
560        }
561    }
562
563    #[rmcp::tool(
564        description = "List raw symbol-table entries in the attached program, optionally narrowed by `kind` (import | export | defined | all, default all) and by a `filter` matched against the symbol name IN the worker (so only matches cross the wire). The filter is a substring match by default; pass regex:true for a regular-expression match (linear-time, length-capped). For a function's signature, calling convention, or size, use find_functions instead — this returns raw symbol-table entries, not function metadata. Paginated: pass offset and limit (default 100); page until has_more is false."
565    )]
566    async fn list_symbols(
567        &self,
568        Parameters(args): Parameters<ListSymbolsArgs>,
569    ) -> Result<CallToolResult, rmcp::ErrorData> {
570        let params = serde_json::json!({
571            "kind": args.kind,
572            "filter": args.filter,
573            "regex": args.regex,
574            "offset": args.offset,
575            "limit": args.limit,
576        });
577        match crate::execute::call_worker(&self.state, "list_symbols", "", "list_symbols", params)
578            .await
579        {
580            Ok((mut v, canon)) => {
581                crate::tools::canonicalize_addr_array(&canon, &mut v, "symbols", "address");
582                Ok(ok_result(&v))
583            }
584            Err(e) => Ok(err_result(&e)),
585        }
586    }
587
588    #[rmcp::tool(
589        description = "List defined string data in the attached program, optionally narrowed by a `filter` matched against the string value IN the worker (so only matches cross the wire). The filter is a substring match by default; pass regex:true for a regular-expression match (linear-time, length-capped) — e.g. to find URL-shaped strings without pulling every string across the wire, use filter:\"https?://.*\", regex:true. Paginated: pass offset and limit (default 200); page until has_more is false."
590    )]
591    async fn list_strings(
592        &self,
593        Parameters(args): Parameters<ListStringsArgs>,
594    ) -> Result<CallToolResult, rmcp::ErrorData> {
595        let params = serde_json::json!({
596            "filter": args.filter,
597            "regex": args.regex,
598            "offset": args.offset,
599            "limit": args.limit,
600        });
601        match crate::execute::call_worker(&self.state, "list_strings", "", "list_strings", params)
602            .await
603        {
604            Ok((mut v, canon)) => {
605                crate::tools::canonicalize_addr_array(&canon, &mut v, "strings", "address");
606                Ok(ok_result(&v))
607            }
608            Err(e) => Ok(err_result(&e)),
609        }
610    }
611
612    #[rmcp::tool(
613        description = "List cross-references to or from `target`, a name-or-address selector (any symbol — function, data label, or string, not just functions). `direction` is one of to | from | both (default both): `to` returns references INTO target, `from` returns references OUT of target. If `target` names multiple symbols you get an AMBIGUOUS_SYMBOL error enumerating each candidate's address — re-call with the disambiguating address. Each result row has from_address, to_address, ref_type (e.g. CALL, DATA, READ), and from_function when the reference's source falls inside a defined function. Paginated: pass offset and limit (default 100); page until has_more is false."
614    )]
615    async fn get_xrefs(
616        &self,
617        Parameters(args): Parameters<GetXrefsArgs>,
618    ) -> Result<CallToolResult, rmcp::ErrorData> {
619        let params = serde_json::json!({
620            "target": args.target,
621            "direction": args.direction,
622            "offset": args.offset,
623            "limit": args.limit,
624        });
625        match crate::execute::call_worker(
626            &self.state,
627            "get_xrefs",
628            &args.target,
629            "get_xrefs",
630            params,
631        )
632        .await
633        {
634            Ok((mut v, canon)) => {
635                crate::tools::canonicalize_addr_array(&canon, &mut v, "xrefs", "from_address");
636                crate::tools::canonicalize_addr_array(&canon, &mut v, "xrefs", "to_address");
637                Ok(ok_result(&v))
638            }
639            Err(e) => Ok(self.err_canon(e).await),
640        }
641    }
642
643    #[rmcp::tool(
644        description = "List the disassembled instructions of one function in the attached program. `target` is EITHER a symbol name OR a canonical address, resolved the same way as inspect_function's `function` parameter. If `target` names multiple functions you get an AMBIGUOUS_SYMBOL error enumerating each candidate's address — re-call with the disambiguating address. Each instruction has address, bytes (lowercase hex), mnemonic, operands, and comment when an EOL comment is present. The reply's `total` is the EXACT instruction count of the function's body. Paginated: pass offset and limit (default 200); page until has_more is false."
645    )]
646    async fn get_disassembly(
647        &self,
648        Parameters(args): Parameters<GetDisassemblyArgs>,
649    ) -> Result<CallToolResult, rmcp::ErrorData> {
650        let params = serde_json::json!({
651            "target": args.target,
652            "offset": args.offset,
653            "limit": args.limit,
654        });
655        match crate::execute::call_worker(
656            &self.state,
657            "get_disassembly",
658            &args.target,
659            "get_disassembly",
660            params,
661        )
662        .await
663        {
664            Ok((mut v, canon)) => {
665                crate::tools::canonicalize_addr_array(&canon, &mut v, "instructions", "address");
666                Ok(ok_result(&v))
667            }
668            Err(e) => Ok(self.err_canon(e).await),
669        }
670    }
671
672    #[rmcp::tool(
673        description = "Read raw bytes at `address` in the attached program, optionally shifted by a signed DECIMAL byte `offset` (default 0) — the reply's `address` echoes address+offset, canonicalized. `length` is the number of bytes to read (default 256), clamped to a maximum of 4096; the reply's `truncated` flag is true when the clamp applied, and `length` then reflects the actual (clamped) byte count returned. `bytes` is lowercase hex. ADDRESS_NOT_FOUND if address+offset is out of bounds or the memory there is not readable."
674    )]
675    async fn read_bytes(
676        &self,
677        Parameters(args): Parameters<ReadBytesArgs>,
678    ) -> Result<CallToolResult, rmcp::ErrorData> {
679        let params = serde_json::json!({
680            "address": args.address,
681            "offset": args.offset,
682            "length": args.length,
683        });
684        match crate::execute::call_worker(
685            &self.state,
686            "read_bytes",
687            &args.address,
688            "read_bytes",
689            params,
690        )
691        .await
692        {
693            Ok((mut v, canon)) => {
694                crate::tools::canonicalize_addr_field(&canon, &mut v, "address");
695                Ok(ok_result(&v))
696            }
697            Err(e) => Ok(err_result(&e)),
698        }
699    }
700
701    #[rmcp::tool(
702        description = "Look up a data type DEFINITION by name (e.g. \"Rect\", \"Color\") in the attached program's data type manager — for defined data INSTANCES/globals use list_data_items instead. Returns `kind` (\"struct\"|\"union\"|\"enum\"|other) and `size`; struct/union kinds also return `members` (name, type, offset, size) and enum kinds return `values` (name, value — value is a STRING, not a number, since Ghidra enum constants can carry full 64-bit values that would lose precision as a bare JSON number). If `name` matches more than one type you get an AMBIGUOUS_DATATYPE error listing each candidate's fully-qualified path in details.candidates — re-call get_datatype with that qualified name."
703    )]
704    async fn get_datatype(
705        &self,
706        Parameters(args): Parameters<GetDatatypeArgs>,
707    ) -> Result<CallToolResult, rmcp::ErrorData> {
708        let params = serde_json::json!({ "name": args.name });
709        match crate::execute::call_worker(
710            &self.state,
711            "get_datatype",
712            &args.name,
713            "get_datatype",
714            params,
715        )
716        .await
717        {
718            Ok((v, _canon)) => Ok(ok_result(&v)),
719            Err(e) => Ok(err_result(&e)),
720        }
721    }
722
723    #[rmcp::tool(
724        description = "List the memory map (segments/blocks) of the attached program: name, start/end (canonical addresses), size, and the readable/writable/executable/initialized flags. NOT paginated — segment counts are small. Use this to distinguish code (e.g. .text, executable=true) from data/bss segments before reading bytes or listing data items."
725    )]
726    async fn list_segments(
727        &self,
728        Parameters(_args): Parameters<ListSegmentsArgs>,
729    ) -> Result<CallToolResult, rmcp::ErrorData> {
730        let params = serde_json::json!({});
731        match crate::execute::call_worker(&self.state, "list_segments", "", "list_segments", params)
732            .await
733        {
734            Ok((mut v, canon)) => {
735                crate::tools::canonicalize_addr_array(&canon, &mut v, "segments", "start");
736                crate::tools::canonicalize_addr_array(&canon, &mut v, "segments", "end");
737                Ok(ok_result(&v))
738            }
739            Err(e) => Ok(err_result(&e)),
740        }
741    }
742
743    #[rmcp::tool(
744        description = "List defined data INSTANCES/globals in the attached program (address, name when a listing label is present, data_type, value when cheaply renderable, size) — for type DEFINITIONS (struct/union/enum layouts) use get_datatype instead. Optionally narrowed by a `filter` matched against the item's label IN the worker (so only matches cross the wire). The filter is a substring match by default; pass regex:true for a regular-expression match (linear-time, length-capped). Paginated: pass offset and limit (default 200); page until has_more is false."
745    )]
746    async fn list_data_items(
747        &self,
748        Parameters(args): Parameters<ListDataItemsArgs>,
749    ) -> Result<CallToolResult, rmcp::ErrorData> {
750        let params = serde_json::json!({
751            "filter": args.filter,
752            "regex": args.regex,
753            "offset": args.offset,
754            "limit": args.limit,
755        });
756        match crate::execute::call_worker(
757            &self.state,
758            "list_data_items",
759            "",
760            "list_data_items",
761            params,
762        )
763        .await
764        {
765            Ok((mut v, canon)) => {
766                crate::tools::canonicalize_addr_array(&canon, &mut v, "data", "address");
767                Ok(ok_result(&v))
768            }
769            Err(e) => Ok(err_result(&e)),
770        }
771    }
772
773    #[rmcp::tool(
774        description = "address → what is here (dual of resolve_symbol); total — mapped:false for an unmapped address, never an error. `address` is a canonical space:offset string. On a mapped address the reply also carries `segment` (the containing memory block's name), `is_code` (true when an instruction starts there), `containing_function` (name + entry_point, when the address falls inside a defined function), `symbol` (name + kind, the primary symbol at the address, when one exists), and `data_type` (when defined data starts there). Use this to identify an address you got from get_xrefs, read_bytes, or elsewhere without knowing in advance whether it names a function, data, or neither."
775    )]
776    async fn describe_address(
777        &self,
778        Parameters(args): Parameters<DescribeAddressArgs>,
779    ) -> Result<CallToolResult, rmcp::ErrorData> {
780        let params = serde_json::json!({ "address": args.address });
781        match crate::execute::call_worker(
782            &self.state,
783            "describe_address",
784            &args.address,
785            "describe_address",
786            params,
787        )
788        .await
789        {
790            Ok((mut v, canon)) => {
791                crate::tools::canonicalize_addr_field(&canon, &mut v, "address");
792                if let Some(cf) = v.get_mut("containing_function") {
793                    crate::tools::canonicalize_addr_field(&canon, cf, "entry_point");
794                }
795                Ok(ok_result(&v))
796            }
797            Err(e) => Ok(err_result(&e)),
798        }
799    }
800
801    #[rmcp::tool(
802        description = "Rename the primary symbol (function or label) at an address. Compare-and-swap: pass expected_name = the symbol's CURRENT name; the rename applies only if it still matches (else RENAME_CONFLICT with the actual name). If it already equals new_name you get status:already_applied. The edit is saved to disk (durable). new_name must be a bare local name (no namespaces). target is an address or the current name."
803    )]
804    async fn rename(
805        &self,
806        Parameters(args): Parameters<RenameArgs>,
807    ) -> Result<CallToolResult, rmcp::ErrorData> {
808        let params = serde_json::json!({
809            "target": args.target, "expected_name": args.expected_name, "new_name": args.new_name
810        });
811        match crate::execute::call_worker(&self.state, "rename", &args.target, "rename", params)
812            .await
813        {
814            Ok((mut v, canon)) => {
815                crate::tools::canonicalize_addr_field(&canon, &mut v, "address");
816                Ok(ok_result(&v))
817            }
818            Err(e) => Ok(self.err_canon(e).await),
819        }
820    }
821
822    #[rmcp::tool(
823        description = "Set or clear a comment on the code unit at an address. comment_type selects the channel (eol|pre|post|plate|repeatable); plate is the function/block header. new_comment is the text; an empty string clears it. Idempotent: if the comment already equals new_comment you get status:already_applied. Pass expected_comment ONLY to guard against drift (else STATE_CONFLICT with the actual value); you do not need to echo the current comment on the normal path. The edit is saved to disk (durable). target is an address or a symbol name."
824    )]
825    async fn comment(
826        &self,
827        Parameters(args): Parameters<CommentArgs>,
828    ) -> Result<CallToolResult, rmcp::ErrorData> {
829        let mut params = serde_json::json!({
830            "target": args.target,
831            "comment_type": args.comment_type.as_str(),
832            "new_comment": args.new_comment,
833        });
834        if let Some(exp) = &args.expected_comment {
835            params["expected_comment"] = serde_json::Value::String(exp.clone());
836        }
837        match crate::execute::call_worker(&self.state, "comment", &args.target, "comment", params)
838            .await
839        {
840            Ok((mut v, canon)) => {
841                crate::tools::canonicalize_addr_field(&canon, &mut v, "address");
842                Ok(ok_result(&v))
843            }
844            Err(e) => Ok(self.err_canon(e).await),
845        }
846    }
847
848    #[rmcp::tool(
849        description = "Apply a named data type to the DATA at an address (retype a global/data location). datatype_name is resolved by name (DATATYPE_NOT_FOUND / AMBIGUOUS_DATATYPE with candidates). The literal \"undefined\" CLEARS the defined data at the address (frees the space). Idempotent: if the location already has an equivalent type you get status:already_applied. Pass expected_datatype ONLY to guard against drift (its value is the current type's display name, or \"undefined\" to assert the location is untyped). Retyping a function PARAMETER is part of set_prototype (whole-signature); retyping a function LOCAL variable is set_local. The edit is saved to disk (durable). target is an address or a symbol name."
850    )]
851    async fn set_datatype(
852        &self,
853        Parameters(args): Parameters<SetDatatypeArgs>,
854    ) -> Result<CallToolResult, rmcp::ErrorData> {
855        let mut params = serde_json::json!({
856            "target": args.target,
857            "datatype_name": args.datatype_name,
858        });
859        if let Some(exp) = &args.expected_datatype {
860            params["expected_datatype"] = serde_json::Value::String(exp.clone());
861        }
862        match crate::execute::call_worker(
863            &self.state,
864            "set_datatype",
865            &args.target,
866            "set_datatype",
867            params,
868        )
869        .await
870        {
871            Ok((mut v, canon)) => {
872                crate::tools::canonicalize_addr_field(&canon, &mut v, "address");
873                Ok(ok_result(&v))
874            }
875            Err(e) => Ok(self.err_canon(e).await),
876        }
877    }
878
879    #[rmcp::tool(
880        description = "Replace a function's whole signature at once: return type, parameters, calling convention, and varargs. Supply the signature as STRUCTURED fields, not a C string. `target` is the function's address or current name. `return_type` and each `params[].type` are resolved by name (DATATYPE_NOT_FOUND / AMBIGUOUS_DATATYPE with candidates); a function-pointer or anonymous type you spell out (e.g. \"void (*)(int)\") is synthesized on the fly. `params` is an ordered list of {name?, type}; an empty list [] means no arguments (do NOT pass [{type:\"void\"}]). `calling_convention` must be one the program recognizes (e.g. __cdecl, __stdcall, __fastcall, or Ghidra's default/unknown) — an unknown one returns INVALID_PARAMS listing the valid set. `variadic:true` adds trailing ... . Read the current signature as these same fields from inspect_function's `prototype`, change what you need, and send the whole thing back. Idempotent: if the signature is already equivalent you get status:already_applied. Pass expected_signature (the current `signature` string from inspect_function) ONLY to guard against drift (else STATE_CONFLICT). Correcting a wrong signature is the highest-leverage edit — it re-derives the decompiled C (fixes in_<reg>/extraout_<reg> noise). A thunk, an external import, or a function with custom variable storage is rejected with INVALID_PARAMS — do not retry it. The edit is saved to disk (durable)."
881    )]
882    async fn set_prototype(
883        &self,
884        Parameters(args): Parameters<SetPrototypeArgs>,
885    ) -> Result<CallToolResult, rmcp::ErrorData> {
886        let mut params = serde_json::json!({
887            "target": args.target,
888            "return_type": args.return_type,
889            "calling_convention": args.calling_convention,
890            "params": args.params.iter().map(|p| {
891                let mut o = serde_json::json!({ "type": p.type_ });
892                if let Some(n) = &p.name {
893                    o["name"] = serde_json::Value::String(n.clone());
894                }
895                o
896            }).collect::<Vec<_>>(),
897            "variadic": args.variadic,
898        });
899        if let Some(exp) = &args.expected_signature {
900            params["expected_signature"] = serde_json::Value::String(exp.clone());
901        }
902        match crate::execute::call_worker(
903            &self.state,
904            "set_prototype",
905            &args.target,
906            "set_prototype",
907            params,
908        )
909        .await
910        {
911            Ok((mut v, canon)) => {
912                crate::tools::canonicalize_addr_field(&canon, &mut v, "address");
913                Ok(ok_result(&v))
914            }
915            Err(e) => Ok(self.err_canon(e).await),
916        }
917    }
918
919    #[rmcp::tool(
920        description = "Retype and/or rename ONE local variable of a function (a decompiler body variable like uVar1 or local_18 — NOT a parameter, which is set_prototype's job, and NOT a global, which is set_datatype's). `target` is the function's address or name. `variable` is the local's STORAGE handle (e.g. \"stack:-0x18\", \"EAX:4\") as shown in inspect_function's `locals[]` — prefer it, because the decompiler silently renames unlocked locals across edits; a bare current name also works but is a weaker handle. Supply `new_type` (resolved by name, DATATYPE_NOT_FOUND / AMBIGUOUS_DATATYPE; a spelled-out fn-pointer/anon type is synthesized) and/or `new_name` (bare, no namespaces); at least one is required. Idempotent: if every field you provide already matches you get status:already_applied. Pass expected_type / expected_name ONLY to guard against drift (else STATE_CONFLICT). A too-large or overlapping type, or a name that collides with another local, returns INVALID_PARAMS with the reason — pick a smaller type or a free name. A miss on the storage/name handle returns VARIABLE_NOT_FOUND (re-read locals[]). The edit is saved to disk (durable), and re-derives the decompiled C. Read locals from inspect_function, change one, send it back."
921    )]
922    async fn set_local(
923        &self,
924        Parameters(args): Parameters<SetLocalArgs>,
925    ) -> Result<CallToolResult, rmcp::ErrorData> {
926        let mut params = serde_json::json!({
927            "target": args.target,
928            "variable": args.variable,
929        });
930        if let Some(t) = &args.new_type {
931            params["new_type"] = serde_json::Value::String(t.clone());
932        }
933        if let Some(n) = &args.new_name {
934            params["new_name"] = serde_json::Value::String(n.clone());
935        }
936        if let Some(t) = &args.expected_type {
937            params["expected_type"] = serde_json::Value::String(t.clone());
938        }
939        if let Some(n) = &args.expected_name {
940            params["expected_name"] = serde_json::Value::String(n.clone());
941        }
942        match crate::execute::call_worker(
943            &self.state,
944            "set_local",
945            &args.target,
946            "set_local",
947            params,
948        )
949        .await
950        {
951            Ok((v, _canon)) => Ok(ok_result(&v)),
952            Err(e) => Ok(self.err_canon(e).await),
953        }
954    }
955}
956
957// `router = self.tool_router` is NOT optional here, and its absence is silent.
958//
959// In rmcp 0.8 the attribute defaulted to the `self.tool_router` FIELD. In 3.1 it
960// defaults to the expression `Self::tool_router()`, which CONSTRUCTS A FRESH
961// ROUTER on every `call_tool`, `list_tools` and `get_tool` — rebuilding all 19
962// tool descriptors and their JSON schemas per request. It still works, which is
963// the problem: the only outward sign is that the field built once in `new()`
964// becomes dead code. Naming the field restores the original design.
965#[rmcp::tool_handler(router = self.tool_router)]
966impl ServerHandler for GhidraMcpServer {
967    fn get_info(&self) -> ServerConfig {
968        ServerConfig::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
969            "ghidra-mcp: inspect a Ghidra project's programs and decompiled functions. Start with \
970             list_project_programs to find a program_path, optionally attach_program to switch \
971             targets, then inspect_function by name or canonical address.",
972        )
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979
980    /// Regression guard: the literal `description = "..."` text embedded in each `#[tool]` attribute
981    /// above must stay byte-for-byte identical to the reviewed `DESC_*` consts in `tools.rs` (the macro
982    /// attribute can't reference a const directly — see the doc-comment note at the top of this file).
983    #[test]
984    fn tool_descriptions_match_tools_rs_consts() {
985        let list = GhidraMcpServer::list_project_programs_tool_attr();
986        let attach = GhidraMcpServer::attach_program_tool_attr();
987        let inspect = GhidraMcpServer::inspect_function_tool_attr();
988        let find = GhidraMcpServer::find_functions_tool_attr();
989        let resolve = GhidraMcpServer::resolve_symbol_tool_attr();
990        let list_symbols = GhidraMcpServer::list_symbols_tool_attr();
991        let list_strings = GhidraMcpServer::list_strings_tool_attr();
992        let get_xrefs = GhidraMcpServer::get_xrefs_tool_attr();
993        let get_disassembly = GhidraMcpServer::get_disassembly_tool_attr();
994        let read_bytes = GhidraMcpServer::read_bytes_tool_attr();
995        let get_datatype = GhidraMcpServer::get_datatype_tool_attr();
996        let list_segments = GhidraMcpServer::list_segments_tool_attr();
997        let list_data_items = GhidraMcpServer::list_data_items_tool_attr();
998        let describe_address = GhidraMcpServer::describe_address_tool_attr();
999        let rename = GhidraMcpServer::rename_tool_attr();
1000        let comment = GhidraMcpServer::comment_tool_attr();
1001        let set_datatype = GhidraMcpServer::set_datatype_tool_attr();
1002        let set_prototype = GhidraMcpServer::set_prototype_tool_attr();
1003        let set_local = GhidraMcpServer::set_local_tool_attr();
1004        assert_eq!(
1005            list.description.as_deref(),
1006            Some(crate::tools::DESC_LIST_PROJECT_PROGRAMS)
1007        );
1008        assert_eq!(
1009            attach.description.as_deref(),
1010            Some(crate::tools::DESC_ATTACH_PROGRAM)
1011        );
1012        assert_eq!(
1013            inspect.description.as_deref(),
1014            Some(crate::tools::DESC_INSPECT_FUNCTION)
1015        );
1016        assert_eq!(
1017            find.description.as_deref(),
1018            Some(crate::tools::DESC_FIND_FUNCTIONS)
1019        );
1020        assert_eq!(
1021            resolve.description.as_deref(),
1022            Some(crate::tools::DESC_RESOLVE_SYMBOL)
1023        );
1024        assert_eq!(
1025            list_symbols.description.as_deref(),
1026            Some(crate::tools::DESC_LIST_SYMBOLS)
1027        );
1028        assert_eq!(
1029            list_strings.description.as_deref(),
1030            Some(crate::tools::DESC_LIST_STRINGS)
1031        );
1032        assert_eq!(
1033            get_xrefs.description.as_deref(),
1034            Some(crate::tools::DESC_GET_XREFS)
1035        );
1036        assert_eq!(
1037            get_disassembly.description.as_deref(),
1038            Some(crate::tools::DESC_GET_DISASSEMBLY)
1039        );
1040        assert_eq!(
1041            read_bytes.description.as_deref(),
1042            Some(crate::tools::DESC_READ_BYTES)
1043        );
1044        assert_eq!(
1045            get_datatype.description.as_deref(),
1046            Some(crate::tools::DESC_GET_DATATYPE)
1047        );
1048        assert_eq!(
1049            list_segments.description.as_deref(),
1050            Some(crate::tools::DESC_LIST_SEGMENTS)
1051        );
1052        assert_eq!(
1053            list_data_items.description.as_deref(),
1054            Some(crate::tools::DESC_LIST_DATA_ITEMS)
1055        );
1056        assert_eq!(
1057            describe_address.description.as_deref(),
1058            Some(crate::tools::DESC_DESCRIBE_ADDRESS)
1059        );
1060        assert_eq!(
1061            rename.description.as_deref(),
1062            Some(crate::tools::DESC_RENAME)
1063        );
1064        assert_eq!(
1065            comment.description.as_deref(),
1066            Some(crate::tools::DESC_COMMENT)
1067        );
1068        assert_eq!(
1069            set_datatype.description.as_deref(),
1070            Some(crate::tools::DESC_SET_DATATYPE)
1071        );
1072        assert_eq!(
1073            set_prototype.description.as_deref(),
1074            Some(crate::tools::DESC_SET_PROTOTYPE)
1075        );
1076        assert_eq!(
1077            set_local.description.as_deref(),
1078            Some(crate::tools::DESC_SET_LOCAL)
1079        );
1080    }
1081
1082    #[test]
1083    fn comment_args_rejects_bad_channel_and_missing_field() {
1084        let bad = serde_json::json!({"target":"main","comment_type":"footnote","new_comment":"x"});
1085        assert!(serde_json::from_value::<CommentArgs>(bad).is_err());
1086        let missing = serde_json::json!({"target":"main","new_comment":"x"});
1087        assert!(serde_json::from_value::<CommentArgs>(missing).is_err());
1088        let ok = serde_json::json!({"target":"main","comment_type":"plate","new_comment":"x"});
1089        assert!(serde_json::from_value::<CommentArgs>(ok).is_ok());
1090    }
1091
1092    #[test]
1093    fn set_datatype_args_schema() {
1094        let missing = serde_json::json!({"target":"main"}); // no datatype_name
1095        assert!(serde_json::from_value::<SetDatatypeArgs>(missing).is_err());
1096        let ok = serde_json::json!({"target":"0x402000","datatype_name":"int"});
1097        assert!(serde_json::from_value::<SetDatatypeArgs>(ok).is_ok());
1098        let guarded =
1099            serde_json::json!({"target":"g","datatype_name":"int","expected_datatype":"undefined"});
1100        assert!(serde_json::from_value::<SetDatatypeArgs>(guarded).is_ok());
1101
1102        // set_prototype: target/return_type/calling_convention/params required; params element needs `type`.
1103        let missing = serde_json::json!({"target":"compute","return_type":"int","calling_convention":"__cdecl"});
1104        assert!(serde_json::from_value::<SetPrototypeArgs>(missing).is_err());
1105        let ok = serde_json::json!({"target":"compute","return_type":"int","calling_convention":"__cdecl","params":[]});
1106        assert!(serde_json::from_value::<SetPrototypeArgs>(ok).is_ok());
1107        let bad_param = serde_json::json!({"target":"c","return_type":"int","calling_convention":"__cdecl","params":[{"name":"a"}]});
1108        assert!(serde_json::from_value::<SetPrototypeArgs>(bad_param).is_err()); // element missing `type`
1109        let full = serde_json::json!({"target":"c","return_type":"int","calling_convention":"__cdecl","params":[{"name":"a","type":"int"}],"variadic":true,"expected_signature":"x"});
1110        assert!(serde_json::from_value::<SetPrototypeArgs>(full).is_ok());
1111    }
1112
1113    #[test]
1114    fn set_local_args_schema_has_target_and_variable() {
1115        let schema = schemars::schema_for!(SetLocalArgs);
1116        let json = serde_json::to_value(&schema).unwrap();
1117        let props = &json["properties"];
1118        assert!(props.get("target").is_some());
1119        assert!(props.get("variable").is_some());
1120        assert!(props.get("new_type").is_some());
1121        assert!(props.get("new_name").is_some());
1122    }
1123}