Skip to main content

gonzalo_mcp/
lib.rs

1//! MCP server exposing the gonzalo code graph to agents (EPIC D).
2//!
3//! [`GonzaloMcp`] implements rmcp's [`ServerHandler`] over a
4//! [`Service`](gonzalo_server::Service): agents spawn the `gonzalo-mcp` binary
5//! (stdio) and call tools that answer from the local store. It exposes a
6//! view-independent `status` and `views` tools plus two families of code-graph
7//! query, each taking a `(repo, view_id)` view selector:
8//!
9//! - **Discovery** — `views` lists the indexed `(repo, view_id)` pairs and
10//!   `status` reports how many exist. A selector naming no indexed view is a
11//!   tool *error* that lists the real ones, never an empty result: the two are
12//!   otherwise indistinguishable, and an agent reads `[]` as "nothing calls
13//!   this" rather than "you asked the wrong question" (#210).
14//! - **Per-symbol** — `search`/`node`/`callers`/`callees`/`impact`/`explore`
15//!   answer questions about a name the caller already has, and `diff` compares
16//!   two views.
17//! - **Whole-view** — `overview`/`top`/`list`/`unreferenced` answer questions
18//!   *about the graph* (what is here, what is heavily referenced, what is
19//!   ambiguous, what nothing calls) so a caller can orient without knowing a
20//!   symbol name first. `unreferenced` is explicitly heuristic; its tool
21//!   description carries the caveats.
22//!
23//! The tool logic lives in plain methods ([`GonzaloMcp::tools`],
24//! [`GonzaloMcp::dispatch`]) so it is unit-testable without an rmcp
25//! [`RequestContext`]; the trait methods are thin adapters over them.
26
27use gonzalo_graph::{Ranking, SymbolFilter, SymbolKind};
28use gonzalo_server::Service;
29use rmcp::handler::server::ServerHandler;
30use rmcp::model::{
31    CallToolRequestMethod, CallToolRequestParams, CallToolResult, Content, ListToolsResult,
32    PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
33};
34use rmcp::service::{RequestContext, RoleServer};
35use serde::Serialize;
36use serde_json::{Map, Value};
37use std::sync::Arc;
38
39/// An MCP server backed by a gonzalo [`Service`].
40#[derive(Clone)]
41pub struct GonzaloMcp {
42    service: Service,
43    root: String,
44}
45
46impl GonzaloMcp {
47    /// Build a server over `service`, reporting `root` (the store location) in
48    /// `status`.
49    pub fn new(service: Service, root: impl Into<String>) -> Self {
50        Self {
51            service,
52            root: root.into(),
53        }
54    }
55
56    /// The backing service (used by the D1b graph tools).
57    pub fn service(&self) -> &Service {
58        &self.service
59    }
60
61    /// The tools this server advertises: a view-independent `status`, the
62    /// per-symbol code-graph queries, and the whole-view aggregates — all the
63    /// graph tools taking a `(repo, view_id)` view selector.
64    pub fn tools() -> Vec<Tool> {
65        vec![
66            Tool::new(
67                "status",
68                "Report that the gonzalo-mcp server is up and its configured store root.",
69                status_schema(),
70            ),
71            Tool::new(
72                "search",
73                "Find where a symbol `name` is defined in a view. Returns located definitions.",
74                view_query_schema(),
75            ),
76            Tool::new(
77                "node",
78                "Inspect a symbol: its definitions, its callers, and its callees in a view.",
79                view_query_schema(),
80            ),
81            Tool::new(
82                "callers",
83                "List the enclosing functions that call `name` in a view.",
84                view_query_schema(),
85            ),
86            Tool::new(
87                "callees",
88                "List the names called from within `name` in a view.",
89                view_query_schema(),
90            ),
91            Tool::new(
92                "impact",
93                "Every symbol transitively affected if `name` changes (the caller closure). Only \
94                 call edges that resolve to a specific definition are followed, so the walk does \
95                 not merge unrelated code that happens to share an identifier; edges that cannot \
96                 be attributed are counted in `ambiguous_edges` rather than traversed, and a \
97                 non-zero count means the true set may be larger. Each result carries the path \
98                 defining it. `truncated` means the walk stopped at `max_depth` with frontier \
99                 left, so completeness is unknown. Note that only calls are edges: `impact` on a \
100                 struct, trait, or type is empty because type usage is not recorded, which means \
101                 \"not applicable\" rather than \"nothing depends on it\".",
102                impact_schema(),
103            ),
104            Tool::new(
105                "explore",
106                "List references to `name` in a view (with their paths), for navigating outward.",
107                view_query_schema(),
108            ),
109            Tool::new(
110                "diff",
111                "Structural diff between two views of a repo: symbols and references added/removed \
112                 going from `view_a` to `view_b`.",
113                diff_schema(),
114            ),
115            Tool::new(
116                "overview",
117                "Summarize a whole view without needing a symbol name: file/symbol/reference \
118                 counts, a breakdown by kind and language, and the largest files. Start here when \
119                 orienting in an unfamiliar repo.",
120                overview_schema(),
121            ),
122            Tool::new(
123                "top",
124                "Rank a view's symbols: `fan_in` (most referenced), `fan_out` (calls the most \
125                 names), or `definitions` (defined in the most places — a score above 1 means the \
126                 name is ambiguous and traversals through it are unreliable).",
127                top_schema(),
128            ),
129            Tool::new(
130                "list",
131                "Enumerate a view's symbols, optionally filtered by path prefix, kind, and name \
132                 substring. Answers \"what is in this crate\" rather than \"where is this name\".",
133                list_schema(),
134            ),
135            Tool::new(
136                "views",
137                "List every indexed view as (repo, view_id) with its file count and the commit it \
138                 was indexed at. Call this first: `repo` and `view_id` must match a view produced \
139                 by `gonzalo index`, and this is the only way to discover the valid values. \
140                 Compare `base_commit` against the checkout's HEAD to spot a stale view.",
141                status_schema(),
142            ),
143            Tool::new(
144                "unreferenced",
145                "Symbols with no inbound reference — dead-code CANDIDATES, not dead code. This is \
146                 a heuristic over a name-matched graph and it does produce false positives. A \
147                 function used only as a value (higher-order usage, e.g. `map_err(be)`) is a path \
148                 expression rather than a call, so it registers nothing and will be reported \
149                 wrongly; and an unused name is hidden by any same-named symbol that is used. \
150                 References from tests and from the symbol itself do count, so test-only and \
151                 recursive-only functions are never reported. Confirm every hit against the \
152                 source before acting on it.",
153                unreferenced_schema(),
154            ),
155        ]
156    }
157
158    /// The `status` payload: server health, the configured store root, and how
159    /// many views are indexed.
160    ///
161    /// The view count is the point (#210): `status` is the tool an agent reaches
162    /// for to self-check, and reporting only `ok` meant a server pointed at an
163    /// empty or wrong store looked perfectly healthy.
164    pub async fn status_json(&self) -> serde_json::Value {
165        match self.service.graph_views().await {
166            Ok(views) => serde_json::json!({
167                "status": "ok",
168                "root": self.root,
169                "views": views.len(),
170            }),
171            Err(e) => serde_json::json!({
172                "status": "degraded",
173                "root": self.root,
174                "error": e.to_string(),
175            }),
176        }
177    }
178
179    /// Dispatch a tool call by name, independent of the rmcp transport so it can
180    /// be unit-tested. Bad arguments and store errors surface as a tool error
181    /// (`CallToolResult::error`); an unknown tool is a `method_not_found` error.
182    pub async fn dispatch(
183        &self,
184        name: &str,
185        arguments: Option<Map<String, Value>>,
186    ) -> Result<CallToolResult, rmcp::ErrorData> {
187        // `status` takes no view selector.
188        if name == "status" {
189            return Ok(CallToolResult::success(vec![Content::text(
190                self.status_json().await.to_string(),
191            )]));
192        }
193
194        // `views` is the discovery tool — it takes no selector by definition.
195        if name == "views" {
196            return self.result(self.service.graph_views().await);
197        }
198
199        // `diff` selects two views instead of one view + a name.
200        if name == "diff" {
201            let (repo, view_a, view_b) = match diff_args(&arguments) {
202                Ok(t) => t,
203                Err(msg) => return Ok(tool_error(msg)),
204            };
205            for view in [&view_a, &view_b] {
206                if let Some(err) = self.unknown_view_error(&repo, view).await {
207                    return Ok(err);
208                }
209            }
210            return self.result(self.service.graph_diff(&repo, &view_a, &view_b).await);
211        }
212
213        // The aggregate tools select a view but take no symbol name.
214        if matches!(name, "overview" | "top" | "list" | "unreferenced") {
215            let (repo, view) = match selector_args(&arguments) {
216                Ok(t) => t,
217                Err(msg) => return Ok(tool_error(msg)),
218            };
219            if let Some(err) = self.unknown_view_error(&repo, &view).await {
220                return Ok(err);
221            }
222            return self.aggregate(name, &repo, &view, &arguments).await;
223        }
224
225        // An unknown tool is `method_not_found` regardless of arguments — decided
226        // before parsing the view selector so it isn't masked by a missing-arg
227        // error.
228        if !matches!(
229            name,
230            "search" | "node" | "callers" | "callees" | "impact" | "explore"
231        ) {
232            return Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>());
233        }
234
235        // Every graph tool selects a view by (repo, view_id) and a `name`.
236        let (repo, view, sym) = match view_args(&arguments) {
237            Ok(t) => t,
238            Err(msg) => return Ok(tool_error(msg)),
239        };
240
241        // An unresolvable selector is an error, never an empty result (#210):
242        // otherwise a typo in `view_id` reads as "nothing calls this".
243        if let Some(err) = self.unknown_view_error(&repo, &view).await {
244            return Ok(err);
245        }
246
247        match name {
248            "search" => self.result(self.service.graph_definitions(&repo, &view, &sym).await),
249            "callers" => self.result(self.service.graph_callers_of(&repo, &view, &sym).await),
250            "callees" => self.result(self.service.graph_callees(&repo, &view, &sym).await),
251            "impact" => {
252                let max_depth = match usize_arg(&arguments, "max_depth", 0) {
253                    Ok(0) => None,
254                    Ok(n) => Some(n),
255                    Err(msg) => return Ok(tool_error(msg)),
256                };
257                self.result(
258                    self.service
259                        .graph_impact(&repo, &view, &sym, max_depth)
260                        .await,
261                )
262            }
263            "explore" => self.result(self.service.graph_references_to(&repo, &view, &sym).await),
264            "node" => self.node(&repo, &view, &sym).await,
265            // Unreachable: the known-tool guard above already returned for any
266            // other name.
267            _ => Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>()),
268        }
269    }
270
271    /// A tool error when `(repo, view_id)` names no indexed view, or `None` when
272    /// the selector resolves.
273    ///
274    /// The message names the unresolved selector *and* lists the views that do
275    /// exist, so a caller that guessed wrong can correct itself in one round
276    /// trip instead of concluding the code is not there (#210).
277    async fn unknown_view_error(&self, repo: &str, view: &str) -> Option<CallToolResult> {
278        match self.service.view_exists(repo, view).await {
279            Ok(true) => None,
280            Ok(false) => {
281                let known = match self.service.graph_views().await {
282                    Ok(views) if !views.is_empty() => views
283                        .iter()
284                        .map(|v| format!("{}/{}", v.repo, v.view_id))
285                        .collect::<Vec<_>>()
286                        .join(", "),
287                    Ok(_) => "none — run `gonzalo index` first".to_string(),
288                    Err(e) => format!("<could not list views: {e}>"),
289                };
290                Some(tool_error(format!(
291                    "no indexed view '{repo}/{view}'. This is a selector error, not an empty \
292                     result. Indexed views: {known}. Call `views` to list them."
293                )))
294            }
295            // A store failure is reported as itself rather than as "no view".
296            Err(e) => Some(tool_error(e.to_string())),
297        }
298    }
299
300    /// Turn a service query outcome into a tool result: JSON on success, a tool
301    /// error carrying the message on failure.
302    fn result<T: Serialize>(
303        &self,
304        outcome: gonzalo_core::Result<T>,
305    ) -> Result<CallToolResult, rmcp::ErrorData> {
306        match outcome {
307            Ok(value) => Ok(success_json(&value)),
308            Err(e) => Ok(tool_error(e.to_string())),
309        }
310    }
311
312    /// Dispatch the view-wide aggregate tools, which take a `(repo, view_id)`
313    /// selector plus their own optional arguments rather than a symbol name.
314    async fn aggregate(
315        &self,
316        name: &str,
317        repo: &str,
318        view: &str,
319        arguments: &Option<Map<String, Value>>,
320    ) -> Result<CallToolResult, rmcp::ErrorData> {
321        match name {
322            "overview" => {
323                let largest = match usize_arg(arguments, "largest", DEFAULT_TOP_LIMIT) {
324                    Ok(n) => n,
325                    Err(msg) => return Ok(tool_error(msg)),
326                };
327                self.result(self.service.graph_overview(repo, view, largest).await)
328            }
329            "top" => {
330                let ranking = match ranking_arg(arguments) {
331                    Ok(r) => r,
332                    Err(msg) => return Ok(tool_error(msg)),
333                };
334                let limit = match usize_arg(arguments, "limit", DEFAULT_TOP_LIMIT) {
335                    Ok(n) => n,
336                    Err(msg) => return Ok(tool_error(msg)),
337                };
338                self.result(self.service.graph_top(repo, view, ranking, limit).await)
339            }
340            "list" => {
341                let filter = match filter_args(arguments) {
342                    Ok(f) => f,
343                    Err(msg) => return Ok(tool_error(msg)),
344                };
345                let limit = match usize_arg(arguments, "limit", DEFAULT_LIST_LIMIT) {
346                    Ok(n) => n,
347                    Err(msg) => return Ok(tool_error(msg)),
348                };
349                self.result(self.service.graph_list(repo, view, &filter, limit).await)
350            }
351            "unreferenced" => {
352                let filter = match filter_args(arguments) {
353                    Ok(f) => f,
354                    Err(msg) => return Ok(tool_error(msg)),
355                };
356                let exclude_tests = match bool_arg(arguments, "exclude_tests", true) {
357                    Ok(b) => b,
358                    Err(msg) => return Ok(tool_error(msg)),
359                };
360                let limit = match usize_arg(arguments, "limit", DEFAULT_LIST_LIMIT) {
361                    Ok(n) => n,
362                    Err(msg) => return Ok(tool_error(msg)),
363                };
364                self.result(
365                    self.service
366                        .graph_unreferenced(repo, view, &filter, exclude_tests, limit)
367                        .await,
368                )
369            }
370            // Unreachable: the caller already matched on these four names.
371            _ => Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>()),
372        }
373    }
374
375    /// The `node` aggregate: definitions + callers + callees for one symbol.
376    async fn node(
377        &self,
378        repo: &str,
379        view: &str,
380        sym: &str,
381    ) -> Result<CallToolResult, rmcp::ErrorData> {
382        let defs = match self.service.graph_definitions(repo, view, sym).await {
383            Ok(d) => d,
384            Err(e) => return Ok(tool_error(e.to_string())),
385        };
386        let callers = match self.service.graph_callers_of(repo, view, sym).await {
387            Ok(c) => c,
388            Err(e) => return Ok(tool_error(e.to_string())),
389        };
390        let callees = match self.service.graph_callees(repo, view, sym).await {
391            Ok(c) => c,
392            Err(e) => return Ok(tool_error(e.to_string())),
393        };
394        let payload = serde_json::json!({
395            "definitions": defs,
396            "callers": callers,
397            "callees": callees,
398        });
399        Ok(success_json(&payload))
400    }
401}
402
403/// Input schema for the view queries: `repo`, `view_id`, and `name` — all
404/// required strings.
405fn view_query_schema() -> Arc<Map<String, Value>> {
406    let schema = serde_json::json!({
407        "type": "object",
408        "properties": {
409            "repo": {
410                "type": "string",
411                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
412                                reported by `views`, not an arbitrary name"
413            },
414            "view_id": {
415                "type": "string",
416                "description": "view id of an INDEXED view, e.g. main — must match one reported \
417                                by `views`"
418            },
419            "name": { "type": "string", "description": "symbol name to query" }
420        },
421        "required": ["repo", "view_id", "name"],
422        "additionalProperties": false
423    });
424    Arc::new(schema.as_object().expect("object schema").clone())
425}
426
427/// Input schema for `diff`: `repo`, `view_a`, and `view_b` — all required.
428fn diff_schema() -> Arc<Map<String, Value>> {
429    let schema = serde_json::json!({
430        "type": "object",
431        "properties": {
432            "repo": {
433                "type": "string",
434                "description": "repository of two INDEXED views — must match `views` output"
435            },
436            "view_a": { "type": "string", "description": "the base view id (must be indexed)" },
437            "view_b": {
438                "type": "string",
439                "description": "the view id to compare against the base (must be indexed)"
440            }
441        },
442        "required": ["repo", "view_a", "view_b"],
443        "additionalProperties": false
444    });
445    Arc::new(schema.as_object().expect("object schema").clone())
446}
447
448/// Default number of entries returned by `overview.largest_files` and `top`.
449const DEFAULT_TOP_LIMIT: usize = 20;
450/// Default number of symbols returned by `list`.
451const DEFAULT_LIST_LIMIT: usize = 100;
452
453/// Input schema for `overview`: a view selector plus an optional cap on the
454/// `largest_files` listing.
455fn overview_schema() -> Arc<Map<String, Value>> {
456    let schema = serde_json::json!({
457        "type": "object",
458        "properties": {
459            "repo": {
460                "type": "string",
461                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
462                                reported by `views`, not an arbitrary name"
463            },
464            "view_id": {
465                "type": "string",
466                "description": "view id of an INDEXED view, e.g. main — must match one reported \
467                                by `views`"
468            },
469            "largest": {
470                "type": "integer",
471                "minimum": 0,
472                "description": "how many of the largest files to list (default 20); the counts \
473                                themselves are never truncated"
474            }
475        },
476        "required": ["repo", "view_id"],
477        "additionalProperties": false
478    });
479    Arc::new(schema.as_object().expect("object schema").clone())
480}
481
482/// Input schema for `top`: a view selector, the required ranking, and a limit.
483fn top_schema() -> Arc<Map<String, Value>> {
484    let schema = serde_json::json!({
485        "type": "object",
486        "properties": {
487            "repo": {
488                "type": "string",
489                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
490                                reported by `views`, not an arbitrary name"
491            },
492            "view_id": {
493                "type": "string",
494                "description": "view id of an INDEXED view, e.g. main — must match one reported \
495                                by `views`"
496            },
497            "by": {
498                "type": "string",
499                "enum": ["fan_in", "fan_out", "definitions"],
500                "description": "fan_in = most referenced; fan_out = calls the most distinct \
501                                names; definitions = defined in the most paths (>1 is ambiguous)"
502            },
503            "limit": {
504                "type": "integer",
505                "minimum": 0,
506                "description": "maximum entries to return (default 20)"
507            }
508        },
509        "required": ["repo", "view_id", "by"],
510        "additionalProperties": false
511    });
512    Arc::new(schema.as_object().expect("object schema").clone())
513}
514
515/// Input schema for `list`: a view selector plus conjunctive filters.
516fn list_schema() -> Arc<Map<String, Value>> {
517    let schema = serde_json::json!({
518        "type": "object",
519        "properties": {
520            "repo": {
521                "type": "string",
522                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
523                                reported by `views`, not an arbitrary name"
524            },
525            "view_id": {
526                "type": "string",
527                "description": "view id of an INDEXED view, e.g. main — must match one reported \
528                                by `views`"
529            },
530            "path_prefix": {
531                "type": "string",
532                "description": "only symbols whose path starts with this (scopes to a crate or \
533                                directory)"
534            },
535            "kind": {
536                "type": "string",
537                "enum": [
538                    "function", "struct", "enum", "trait", "impl", "module",
539                    "const", "static", "type_alias", "class", "interface"
540                ],
541                "description": "only symbols of this kind"
542            },
543            "name_contains": {
544                "type": "string",
545                "description": "only symbols whose name contains this substring"
546            },
547            "limit": {
548                "type": "integer",
549                "minimum": 0,
550                "description": "maximum symbols to return (default 100)"
551            }
552        },
553        "required": ["repo", "view_id"],
554        "additionalProperties": false
555    });
556    Arc::new(schema.as_object().expect("object schema").clone())
557}
558
559/// Input schema for `unreferenced`: the `list` filters plus the test-scope
560/// toggle.
561fn unreferenced_schema() -> Arc<Map<String, Value>> {
562    let schema = serde_json::json!({
563        "type": "object",
564        "properties": {
565            "repo": {
566                "type": "string",
567                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
568                                reported by `views`, not an arbitrary name"
569            },
570            "view_id": {
571                "type": "string",
572                "description": "view id of an INDEXED view, e.g. main — must match one reported \
573                                by `views`"
574            },
575            "path_prefix": {
576                "type": "string",
577                "description": "only symbols whose path starts with this (scopes to a crate or \
578                                directory)"
579            },
580            "kind": {
581                "type": "string",
582                "enum": [
583                    "function", "struct", "enum", "trait", "impl", "module",
584                    "const", "static", "type_alias", "class", "interface"
585                ],
586                "description": "only symbols of this kind; `function` is usually what you want"
587            },
588            "name_contains": {
589                "type": "string",
590                "description": "only symbols whose name contains this substring"
591            },
592            "exclude_tests": {
593                "type": "boolean",
594                "description": "drop symbols inside a `mod tests`/`mod test` block or under a \
595                                tests/ directory (default true); without it the result is mostly \
596                                test helpers"
597            },
598            "limit": {
599                "type": "integer",
600                "minimum": 0,
601                "description": "maximum candidates to return (default 100)"
602            }
603        },
604        "required": ["repo", "view_id"],
605        "additionalProperties": false
606    });
607    Arc::new(schema.as_object().expect("object schema").clone())
608}
609
610/// Input schema for `impact`: the per-symbol selector plus an optional depth cap.
611fn impact_schema() -> Arc<Map<String, Value>> {
612    let schema = serde_json::json!({
613        "type": "object",
614        "properties": {
615            "repo": {
616                "type": "string",
617                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
618                                reported by `views`, not an arbitrary name"
619            },
620            "view_id": {
621                "type": "string",
622                "description": "view id of an INDEXED view, e.g. main — must match one reported \
623                                by `views`"
624            },
625            "name": { "type": "string", "description": "symbol name to seed the closure from" },
626            "max_depth": {
627                "type": "integer",
628                "minimum": 0,
629                "description": "stop after this many hops (0 or omitted = unbounded); the result \
630                                reports `truncated` when a cap stopped it early"
631            }
632        },
633        "required": ["repo", "view_id", "name"],
634        "additionalProperties": false
635    });
636    Arc::new(schema.as_object().expect("object schema").clone())
637}
638
639/// A minimal object schema for the argument-free `status` tool.
640fn status_schema() -> Arc<Map<String, Value>> {
641    let schema = serde_json::json!({ "type": "object", "additionalProperties": false });
642    Arc::new(schema.as_object().expect("object schema").clone())
643}
644
645/// Extract a required string argument by key.
646fn str_arg(arguments: &Option<Map<String, Value>>, key: &str) -> Result<String, String> {
647    arguments
648        .as_ref()
649        .and_then(|m| m.get(key))
650        .and_then(Value::as_str)
651        .map(str::to_string)
652        .ok_or_else(|| format!("missing required string argument '{key}'"))
653}
654
655/// Extract the required `(repo, view_id, name)` view selector from tool args.
656fn view_args(arguments: &Option<Map<String, Value>>) -> Result<(String, String, String), String> {
657    Ok((
658        str_arg(arguments, "repo")?,
659        str_arg(arguments, "view_id")?,
660        str_arg(arguments, "name")?,
661    ))
662}
663
664/// Extract the required `(repo, view_id)` selector, for tools that address a
665/// whole view rather than a symbol in it.
666fn selector_args(arguments: &Option<Map<String, Value>>) -> Result<(String, String), String> {
667    Ok((str_arg(arguments, "repo")?, str_arg(arguments, "view_id")?))
668}
669
670/// Extract an optional non-negative integer argument, or `default` if absent.
671fn usize_arg(
672    arguments: &Option<Map<String, Value>>,
673    key: &str,
674    default: usize,
675) -> Result<usize, String> {
676    match arguments.as_ref().and_then(|m| m.get(key)) {
677        None | Some(Value::Null) => Ok(default),
678        Some(v) => v
679            .as_u64()
680            .map(|n| n as usize)
681            .ok_or_else(|| format!("argument '{key}' must be a non-negative integer, got {v}")),
682    }
683}
684
685/// Extract an optional boolean argument, or `default` if absent.
686fn bool_arg(
687    arguments: &Option<Map<String, Value>>,
688    key: &str,
689    default: bool,
690) -> Result<bool, String> {
691    match arguments.as_ref().and_then(|m| m.get(key)) {
692        None | Some(Value::Null) => Ok(default),
693        Some(Value::Bool(b)) => Ok(*b),
694        Some(v) => Err(format!("argument '{key}' must be a boolean, got {v}")),
695    }
696}
697
698/// Extract the required `by` ranking for `top`.
699fn ranking_arg(arguments: &Option<Map<String, Value>>) -> Result<Ranking, String> {
700    let raw = str_arg(arguments, "by")?;
701    match raw.as_str() {
702        "fan_in" => Ok(Ranking::FanIn),
703        "fan_out" => Ok(Ranking::FanOut),
704        "definitions" => Ok(Ranking::Definitions),
705        other => Err(format!(
706            "unknown ranking '{other}': expected one of fan_in, fan_out, definitions"
707        )),
708    }
709}
710
711/// Build the `list` filter from its optional arguments.
712fn filter_args(arguments: &Option<Map<String, Value>>) -> Result<SymbolFilter, String> {
713    let mut filter = SymbolFilter::default();
714    if let Some(prefix) = opt_str_arg(arguments, "path_prefix")? {
715        filter = filter.path_prefix(prefix);
716    }
717    if let Some(needle) = opt_str_arg(arguments, "name_contains")? {
718        filter = filter.name_contains(needle);
719    }
720    if let Some(raw) = opt_str_arg(arguments, "kind")? {
721        filter = filter.kind(parse_kind(&raw)?);
722    }
723    Ok(filter)
724}
725
726/// Extract an optional string argument, erroring if present but not a string.
727fn opt_str_arg(
728    arguments: &Option<Map<String, Value>>,
729    key: &str,
730) -> Result<Option<String>, String> {
731    match arguments.as_ref().and_then(|m| m.get(key)) {
732        None | Some(Value::Null) => Ok(None),
733        Some(Value::String(s)) => Ok(Some(s.clone())),
734        Some(v) => Err(format!("argument '{key}' must be a string, got {v}")),
735    }
736}
737
738/// Parse a [`SymbolKind`] from its lowercase wire name.
739fn parse_kind(raw: &str) -> Result<SymbolKind, String> {
740    [
741        SymbolKind::Function,
742        SymbolKind::Struct,
743        SymbolKind::Enum,
744        SymbolKind::Trait,
745        SymbolKind::Impl,
746        SymbolKind::Module,
747        SymbolKind::Const,
748        SymbolKind::Static,
749        SymbolKind::TypeAlias,
750        SymbolKind::Class,
751        SymbolKind::Interface,
752    ]
753    .into_iter()
754    .find(|k| k.as_str() == raw)
755    .ok_or_else(|| format!("unknown kind '{raw}'"))
756}
757
758/// Extract the required `(repo, view_a, view_b)` selector for `diff`.
759fn diff_args(arguments: &Option<Map<String, Value>>) -> Result<(String, String, String), String> {
760    Ok((
761        str_arg(arguments, "repo")?,
762        str_arg(arguments, "view_a")?,
763        str_arg(arguments, "view_b")?,
764    ))
765}
766
767/// A tool error carrying `msg` as text (`isError = true`).
768fn tool_error(msg: impl Into<String>) -> CallToolResult {
769    CallToolResult::error(vec![Content::text(msg.into())])
770}
771
772/// A successful tool result whose single text block is `value` as JSON.
773fn success_json<T: Serialize>(value: &T) -> CallToolResult {
774    match serde_json::to_string(value) {
775        Ok(json) => CallToolResult::success(vec![Content::text(json)]),
776        Err(e) => tool_error(format!("failed to serialize result: {e}")),
777    }
778}
779
780impl ServerHandler for GonzaloMcp {
781    fn get_info(&self) -> ServerInfo {
782        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
783            .with_instructions("gonzalo-mcp: code-graph queries over a gonzalo view")
784    }
785
786    async fn list_tools(
787        &self,
788        _request: Option<PaginatedRequestParams>,
789        _context: RequestContext<RoleServer>,
790    ) -> Result<ListToolsResult, rmcp::ErrorData> {
791        Ok(ListToolsResult::with_all_items(Self::tools()))
792    }
793
794    async fn call_tool(
795        &self,
796        request: CallToolRequestParams,
797        _context: RequestContext<RoleServer>,
798    ) -> Result<CallToolResult, rmcp::ErrorData> {
799        self.dispatch(request.name.as_ref(), request.arguments)
800            .await
801    }
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use gonzalo_core::{
808        BlobStore, Identity, Manifest, Meta, PutResult, Record, RecordKind, Revision, Store,
809    };
810    use gonzalo_graph::build_rust;
811    use gonzalo_store_fs::FsStore;
812    use std::collections::BTreeMap;
813
814    fn server(root: &str) -> GonzaloMcp {
815        let fs = Arc::new(FsStore::new(tempfile::tempdir().unwrap().keep()));
816        GonzaloMcp::new(Service::new(fs.clone(), fs), root)
817    }
818
819    /// A server whose store already holds view `r`/`main` with two slices.
820    async fn seeded_server() -> GonzaloMcp {
821        seeded_with(&[
822            ("lib.rs", "fn helper() {}"),
823            ("main.rs", "fn main() { helper(); }"),
824        ])
825        .await
826    }
827
828    /// A server whose store holds view `r`/`main` assembled from `slices`.
829    async fn seeded_with(slices: &[(&str, &str)]) -> GonzaloMcp {
830        let fs = Arc::new(FsStore::new(tempfile::tempdir().unwrap().keep()));
831        let mut manifest = Manifest::new();
832        for &(path, src) in slices {
833            let hash = fs
834                .put_blob(&build_rust(src).to_slice_bytes())
835                .await
836                .unwrap();
837            manifest.insert(path, hash);
838        }
839        let body = manifest.to_body();
840        let record = Record {
841            revision: Revision::initial(body.bytes()),
842            parent: None,
843            body,
844            kind: RecordKind::GraphManifest,
845            meta: Meta {
846                author: Identity::new("tester"),
847                origin_system: "test".into(),
848                created: 0,
849                updated: 0,
850                labels: BTreeMap::new(),
851            },
852            links: Vec::new(),
853            key: Manifest::key("r", "main"),
854        };
855        assert!(matches!(
856            fs.put(record, None).await.unwrap(),
857            PutResult::Committed(_)
858        ));
859        GonzaloMcp::new(Service::new(fs.clone(), fs), "test-root")
860    }
861
862    fn args(repo: &str, view: &str, name: &str) -> Option<Map<String, Value>> {
863        Some(
864            serde_json::json!({ "repo": repo, "view_id": view, "name": name })
865                .as_object()
866                .unwrap()
867                .clone(),
868        )
869    }
870
871    /// The text of a result's first content block (via the serialized result).
872    fn result_text(result: &CallToolResult) -> String {
873        let v = serde_json::to_value(result).unwrap();
874        v["content"][0]["text"]
875            .as_str()
876            .expect("text content")
877            .to_string()
878    }
879
880    #[test]
881    fn advertises_status_and_the_graph_tools() {
882        let names: Vec<String> = GonzaloMcp::tools()
883            .iter()
884            .map(|t| t.name.to_string())
885            .collect();
886        assert_eq!(
887            names,
888            vec![
889                "status",
890                "search",
891                "node",
892                "callers",
893                "callees",
894                "impact",
895                "explore",
896                "diff",
897                "overview",
898                "top",
899                "list",
900                "views",
901                "unreferenced",
902            ]
903        );
904    }
905
906    // ---- aggregate / structural tools (#214) ------------------------------
907
908    /// `(repo, view_id)` plus whatever extra arguments a tool takes.
909    fn view_args_with(repo: &str, view: &str, extra: Value) -> Option<Map<String, Value>> {
910        let mut map = serde_json::json!({ "repo": repo, "view_id": view })
911            .as_object()
912            .unwrap()
913            .clone();
914        for (k, v) in extra.as_object().expect("object") {
915            map.insert(k.clone(), v.clone());
916        }
917        Some(map)
918    }
919
920    async fn call(tool: &str, extra: Value) -> Value {
921        let s = seeded_server().await;
922        let result = s
923            .dispatch(tool, view_args_with("r", "main", extra))
924            .await
925            .unwrap();
926        assert_ne!(result.is_error, Some(true), "tool {tool} errored");
927        serde_json::from_str(&result_text(&result)).unwrap()
928    }
929
930    #[tokio::test]
931    async fn overview_tool_reports_the_shape_of_the_view() {
932        // The seeded view is lib.rs (`helper`) + main.rs (`main` calling helper).
933        let v = call("overview", serde_json::json!({})).await;
934        assert_eq!(v["files"], 2);
935        assert_eq!(v["symbols"], 2);
936        assert_eq!(v["references"], 1);
937        assert_eq!(v["by_kind"]["function"], 2);
938        assert_eq!(v["by_language"]["rust"], 2);
939        assert_eq!(v["largest_files"].as_array().unwrap().len(), 2);
940    }
941
942    #[tokio::test]
943    async fn overview_bounds_the_largest_files_listing() {
944        let v = call("overview", serde_json::json!({ "largest": 1 })).await;
945        assert_eq!(v["largest_files"].as_array().unwrap().len(), 1);
946        assert_eq!(v["files"], 2, "the count itself is not truncated");
947    }
948
949    #[tokio::test]
950    async fn top_tool_ranks_by_fan_in() {
951        let v = call("top", serde_json::json!({ "by": "fan_in" })).await;
952        let helper = v["items"]
953            .as_array()
954            .unwrap()
955            .iter()
956            .find(|r| r["name"] == "helper")
957            .expect("helper is referenced once");
958        assert_eq!(helper["score"], 1);
959        assert_eq!(helper["paths"], serde_json::json!(["lib.rs"]));
960    }
961
962    #[tokio::test]
963    async fn top_tool_ranks_by_definitions_for_the_ambiguity_report() {
964        let v = call("top", serde_json::json!({ "by": "definitions" })).await;
965        // Nothing is ambiguous in this view: every name has exactly one home.
966        assert!(
967            v["items"]
968                .as_array()
969                .unwrap()
970                .iter()
971                .all(|r| r["score"] == 1)
972        );
973    }
974
975    #[tokio::test]
976    async fn top_tool_reports_truncation() {
977        // Two names are defined in the seeded view (`helper`, `main`), so a
978        // limit of 1 must report that something was left out.
979        let v = call(
980            "top",
981            serde_json::json!({ "by": "definitions", "limit": 1 }),
982        )
983        .await;
984        assert_eq!(v["items"].as_array().unwrap().len(), 1);
985        assert_eq!(v["total"], 2);
986        assert_eq!(v["truncated"], true);
987    }
988
989    #[tokio::test]
990    async fn top_tool_rejects_an_unknown_ranking() {
991        let s = seeded_server().await;
992        let result = s
993            .dispatch(
994                "top",
995                view_args_with("r", "main", serde_json::json!({ "by": "sideways" })),
996            )
997            .await
998            .unwrap();
999        assert_eq!(result.is_error, Some(true));
1000        assert!(result_text(&result).contains("sideways"));
1001    }
1002
1003    #[tokio::test]
1004    async fn top_tool_requires_the_ranking_argument() {
1005        let s = seeded_server().await;
1006        let result = s
1007            .dispatch("top", view_args_with("r", "main", serde_json::json!({})))
1008            .await
1009            .unwrap();
1010        assert_eq!(result.is_error, Some(true));
1011        assert!(result_text(&result).contains("by"));
1012    }
1013
1014    #[tokio::test]
1015    async fn list_tool_filters_by_kind() {
1016        let v = call("list", serde_json::json!({ "kind": "function" })).await;
1017        assert_eq!(v["total"], 2);
1018        assert!(
1019            v["items"]
1020                .as_array()
1021                .unwrap()
1022                .iter()
1023                .all(|l| l["item"]["kind"] == "function")
1024        );
1025    }
1026
1027    #[tokio::test]
1028    async fn list_tool_filters_by_path_prefix() {
1029        let v = call("list", serde_json::json!({ "path_prefix": "lib" })).await;
1030        assert_eq!(v["total"], 1);
1031        assert_eq!(v["items"][0]["path"], "lib.rs");
1032    }
1033
1034    #[tokio::test]
1035    async fn list_tool_reports_truncation() {
1036        let v = call("list", serde_json::json!({ "limit": 1 })).await;
1037        assert_eq!(v["items"].as_array().unwrap().len(), 1);
1038        assert_eq!(v["truncated"], true);
1039        assert_eq!(v["total"], 2);
1040    }
1041
1042    #[tokio::test]
1043    async fn list_tool_rejects_an_unknown_kind() {
1044        let s = seeded_server().await;
1045        let result = s
1046            .dispatch(
1047                "list",
1048                view_args_with("r", "main", serde_json::json!({ "kind": "gizmo" })),
1049            )
1050            .await
1051            .unwrap();
1052        assert_eq!(result.is_error, Some(true));
1053        assert!(result_text(&result).contains("gizmo"));
1054    }
1055
1056    // ---- unknown view vs empty result (#210) -------------------------------
1057
1058    /// Every tool that takes a view selector, with valid arguments except the
1059    /// view id.
1060    const VIEW_SCOPED: &[&str] = &[
1061        "search",
1062        "node",
1063        "callers",
1064        "callees",
1065        "impact",
1066        "explore",
1067        "overview",
1068        "top",
1069        "list",
1070        "unreferenced",
1071    ];
1072
1073    fn args_for(tool: &str, repo: &str, view: &str) -> Option<Map<String, Value>> {
1074        let mut m = serde_json::json!({ "repo": repo, "view_id": view })
1075            .as_object()
1076            .unwrap()
1077            .clone();
1078        // Per-tool required extras.
1079        if matches!(
1080            tool,
1081            "search" | "node" | "callers" | "callees" | "impact" | "explore"
1082        ) {
1083            m.insert("name".into(), Value::String("helper".into()));
1084        }
1085        if tool == "top" {
1086            m.insert("by".into(), Value::String("fan_in".into()));
1087        }
1088        Some(m)
1089    }
1090
1091    #[tokio::test]
1092    async fn an_unknown_view_id_is_an_error_not_an_empty_result() {
1093        let s = seeded_server().await;
1094        for tool in VIEW_SCOPED {
1095            let result = s.dispatch(tool, args_for(tool, "r", "mian")).await.unwrap();
1096            assert_eq!(result.is_error, Some(true), "{tool} must reject 'mian'");
1097            let text = result_text(&result);
1098            assert!(
1099                text.contains("r/mian"),
1100                "{tool} must name the selector: {text}"
1101            );
1102        }
1103    }
1104
1105    #[tokio::test]
1106    async fn an_unknown_repo_is_an_error_not_an_empty_result() {
1107        let s = seeded_server().await;
1108        for tool in VIEW_SCOPED {
1109            let result = s
1110                .dispatch(tool, args_for(tool, "does-not-exist", "main"))
1111                .await
1112                .unwrap();
1113            assert_eq!(result.is_error, Some(true), "{tool} must reject the repo");
1114        }
1115    }
1116
1117    #[tokio::test]
1118    async fn an_unknown_view_error_lists_the_views_that_exist() {
1119        let s = seeded_server().await;
1120        let result = s
1121            .dispatch("search", args_for("search", "r", "mian"))
1122            .await
1123            .unwrap();
1124        let text = result_text(&result);
1125        assert!(
1126            text.contains("r/main"),
1127            "must point at the real view: {text}"
1128        );
1129        assert!(
1130            text.contains("views"),
1131            "must name the discovery tool: {text}"
1132        );
1133    }
1134
1135    #[tokio::test]
1136    async fn an_absent_symbol_in_a_valid_view_is_still_an_empty_result() {
1137        // The other half of #210: the two cases must be distinguishable, so a
1138        // genuine miss must NOT become an error.
1139        let s = seeded_server().await;
1140        let result = s
1141            .dispatch("search", args_for("search", "r", "main"))
1142            .await
1143            .unwrap();
1144        assert_ne!(result.is_error, Some(true));
1145
1146        let result = s
1147            .dispatch(
1148                "callers",
1149                Some(
1150                    serde_json::json!({ "repo": "r", "view_id": "main", "name": "nonexistent" })
1151                        .as_object()
1152                        .unwrap()
1153                        .clone(),
1154                ),
1155            )
1156            .await
1157            .unwrap();
1158        assert_ne!(result.is_error, Some(true), "a real miss is not an error");
1159        assert_eq!(
1160            serde_json::from_str::<Value>(&result_text(&result)).unwrap(),
1161            serde_json::json!([])
1162        );
1163    }
1164
1165    #[tokio::test]
1166    async fn diff_rejects_an_unknown_view_on_either_side() {
1167        let s = seeded_server().await;
1168        for (a, b) in [("main", "nope"), ("nope", "main")] {
1169            let result = s
1170                .dispatch(
1171                    "diff",
1172                    Some(
1173                        serde_json::json!({ "repo": "r", "view_a": a, "view_b": b })
1174                            .as_object()
1175                            .unwrap()
1176                            .clone(),
1177                    ),
1178                )
1179                .await
1180                .unwrap();
1181            assert_eq!(result.is_error, Some(true), "diff {a}->{b}");
1182            assert!(result_text(&result).contains("r/nope"));
1183        }
1184    }
1185
1186    #[tokio::test]
1187    async fn views_tool_lists_indexed_views() {
1188        let s = seeded_server().await;
1189        let result = s.dispatch("views", None).await.unwrap();
1190        assert_ne!(result.is_error, Some(true));
1191        let v: Value = serde_json::from_str(&result_text(&result)).unwrap();
1192        assert_eq!(v.as_array().unwrap().len(), 1);
1193        assert_eq!(v[0]["repo"], "r");
1194        assert_eq!(v[0]["view_id"], "main");
1195        assert_eq!(v[0]["files"], 2, "lib.rs + main.rs");
1196    }
1197
1198    #[tokio::test]
1199    async fn views_tool_is_empty_on_a_fresh_store() {
1200        let s = server("test-root");
1201        let result = s.dispatch("views", None).await.unwrap();
1202        let v: Value = serde_json::from_str(&result_text(&result)).unwrap();
1203        assert!(v.as_array().unwrap().is_empty());
1204    }
1205
1206    #[tokio::test]
1207    async fn status_reports_the_number_of_indexed_views() {
1208        let s = seeded_server().await;
1209        let v: Value =
1210            serde_json::from_str(&result_text(&s.dispatch("status", None).await.unwrap())).unwrap();
1211        assert_eq!(v["status"], "ok");
1212        assert_eq!(v["views"], 1, "an empty store must be distinguishable");
1213
1214        let empty = server("test-root");
1215        let v: Value =
1216            serde_json::from_str(&result_text(&empty.dispatch("status", None).await.unwrap()))
1217                .unwrap();
1218        assert_eq!(v["views"], 0);
1219    }
1220
1221    #[tokio::test]
1222    async fn aggregate_tools_require_a_view_selector() {
1223        let s = seeded_server().await;
1224        for tool in ["overview", "top", "list", "unreferenced"] {
1225            let result = s.dispatch(tool, None).await.unwrap();
1226            assert_eq!(result.is_error, Some(true), "{tool} should reject no args");
1227            assert!(result_text(&result).contains("repo"));
1228        }
1229    }
1230
1231    /// A view with a used function, an unused one, and a `mod tests` block.
1232    const DEAD_SRC: &str = "fn helper() {}\n\
1233                            fn main() { helper(); }\n\
1234                            fn orphan() {}\n\
1235                            #[cfg(test)]\n\
1236                            mod tests {\n    \
1237                                fn t_only() {}\n\
1238                            }\n";
1239
1240    async fn call_on(server: &GonzaloMcp, tool: &str, extra: Value) -> Value {
1241        let result = server
1242            .dispatch(tool, view_args_with("r", "main", extra))
1243            .await
1244            .unwrap();
1245        assert_ne!(result.is_error, Some(true), "tool {tool} errored");
1246        serde_json::from_str(&result_text(&result)).unwrap()
1247    }
1248
1249    fn item_names(v: &Value) -> Vec<String> {
1250        v["items"]
1251            .as_array()
1252            .unwrap()
1253            .iter()
1254            .map(|l| l["item"]["name"].as_str().unwrap().to_string())
1255            .collect()
1256    }
1257
1258    #[tokio::test]
1259    async fn unreferenced_tool_reports_only_uncalled_symbols() {
1260        let v = call("unreferenced", serde_json::json!({})).await;
1261        // The seeded view is `helper` (called from main) + `main` (called by
1262        // nothing), so only `main` is a candidate.
1263        assert_eq!(item_names(&v), vec!["main"]);
1264        assert_eq!(v["items"][0]["path"], "main.rs");
1265    }
1266
1267    #[tokio::test]
1268    async fn unreferenced_tool_excludes_test_scopes_by_default() {
1269        let s = seeded_with(&[("lib.rs", DEAD_SRC)]).await;
1270        let v = call_on(&s, "unreferenced", serde_json::json!({})).await;
1271        let names = item_names(&v);
1272        assert!(names.contains(&"orphan".to_string()));
1273        assert!(!names.contains(&"t_only".to_string()));
1274    }
1275
1276    #[tokio::test]
1277    async fn unreferenced_tool_can_include_test_scopes() {
1278        let s = seeded_with(&[("lib.rs", DEAD_SRC)]).await;
1279        let v = call_on(
1280            &s,
1281            "unreferenced",
1282            serde_json::json!({ "exclude_tests": false }),
1283        )
1284        .await;
1285        assert!(item_names(&v).contains(&"t_only".to_string()));
1286    }
1287
1288    #[tokio::test]
1289    async fn unreferenced_tool_applies_the_symbol_filter() {
1290        let s = seeded_with(&[("lib.rs", DEAD_SRC)]).await;
1291        let v = call_on(&s, "unreferenced", serde_json::json!({ "kind": "struct" })).await;
1292        assert_eq!(v["total"], 0);
1293    }
1294
1295    #[tokio::test]
1296    async fn unreferenced_tool_reports_truncation() {
1297        let s = seeded_with(&[("lib.rs", "fn a() {} fn b() {} fn c() {}")]).await;
1298        let v = call_on(&s, "unreferenced", serde_json::json!({ "limit": 2 })).await;
1299        assert_eq!(v["items"].as_array().unwrap().len(), 2);
1300        assert_eq!(v["truncated"], true);
1301        assert_eq!(v["total"], 3);
1302    }
1303
1304    #[tokio::test]
1305    async fn unreferenced_tool_rejects_a_non_boolean_exclude_tests() {
1306        let s = seeded_server().await;
1307        let result = s
1308            .dispatch(
1309                "unreferenced",
1310                view_args_with("r", "main", serde_json::json!({ "exclude_tests": "yes" })),
1311            )
1312            .await
1313            .unwrap();
1314        assert_eq!(result.is_error, Some(true));
1315        assert!(result_text(&result).contains("exclude_tests"));
1316    }
1317
1318    #[test]
1319    fn unreferenced_tool_description_states_the_heuristic_blind_spot() {
1320        let tool = GonzaloMcp::tools()
1321            .into_iter()
1322            .find(|t| t.name == "unreferenced")
1323            .expect("unreferenced is advertised");
1324        let description = tool.description.as_deref().unwrap_or_default();
1325        assert!(
1326            description.contains("heuristic"),
1327            "must not present candidates as proof: {description}"
1328        );
1329        assert!(
1330            description.contains("higher-order"),
1331            "must name the higher-order-usage blind spot: {description}"
1332        );
1333    }
1334
1335    #[tokio::test]
1336    async fn diff_tool_reports_changes_between_two_views() {
1337        // Seed two views of `r` sharing the store, differing by one symbol.
1338        let fs = Arc::new(FsStore::new(tempfile::tempdir().unwrap().keep()));
1339        for (view, src) in [
1340            ("v1", "fn keep() {}\nfn gone() {}"),
1341            ("v2", "fn keep() {}\nfn fresh() {}"),
1342        ] {
1343            let hash = fs
1344                .put_blob(&build_rust(src).to_slice_bytes())
1345                .await
1346                .unwrap();
1347            let mut manifest = Manifest::new();
1348            manifest.insert("lib.rs", hash);
1349            let body = manifest.to_body();
1350            let record = Record {
1351                revision: Revision::initial(body.bytes()),
1352                parent: None,
1353                body,
1354                kind: RecordKind::GraphManifest,
1355                meta: Meta {
1356                    author: Identity::new("t"),
1357                    origin_system: "t".into(),
1358                    created: 0,
1359                    updated: 0,
1360                    labels: BTreeMap::new(),
1361                },
1362                links: Vec::new(),
1363                key: Manifest::key("r", view),
1364            };
1365            assert!(matches!(
1366                fs.put(record, None).await.unwrap(),
1367                PutResult::Committed(_)
1368            ));
1369        }
1370        let mcp = GonzaloMcp::new(Service::new(fs.clone(), fs), "test-root");
1371
1372        let diff_args = Some(
1373            serde_json::json!({ "repo": "r", "view_a": "v1", "view_b": "v2" })
1374                .as_object()
1375                .unwrap()
1376                .clone(),
1377        );
1378        let result = mcp.dispatch("diff", diff_args).await.unwrap();
1379        let diff: Value = serde_json::from_str(&result_text(&result)).unwrap();
1380        let added: Vec<&str> = diff["added_symbols"]
1381            .as_array()
1382            .unwrap()
1383            .iter()
1384            .map(|s| s["item"]["name"].as_str().unwrap())
1385            .collect();
1386        assert!(added.contains(&"fresh"));
1387        let removed: Vec<&str> = diff["removed_symbols"]
1388            .as_array()
1389            .unwrap()
1390            .iter()
1391            .map(|s| s["item"]["name"].as_str().unwrap())
1392            .collect();
1393        assert!(removed.contains(&"gone"));
1394    }
1395
1396    #[tokio::test]
1397    async fn status_reports_ok_and_configured_root() {
1398        let s = server("/tmp/some-root");
1399        let payload = s.status_json().await;
1400        assert_eq!(payload["status"], "ok");
1401        assert_eq!(payload["root"], "/tmp/some-root");
1402    }
1403
1404    #[tokio::test]
1405    async fn search_returns_located_definitions() {
1406        let s = seeded_server().await;
1407        let result = s
1408            .dispatch("search", args("r", "main", "helper"))
1409            .await
1410            .unwrap();
1411        assert_ne!(result.is_error, Some(true));
1412        let defs: Value = serde_json::from_str(&result_text(&result)).unwrap();
1413        assert_eq!(defs[0]["path"], "lib.rs");
1414        assert_eq!(defs[0]["item"]["name"], "helper");
1415    }
1416
1417    #[tokio::test]
1418    async fn callees_returns_names() {
1419        let s = seeded_server().await;
1420        let callees = s
1421            .dispatch("callees", args("r", "main", "main"))
1422            .await
1423            .unwrap();
1424        let names: Value = serde_json::from_str(&result_text(&callees)).unwrap();
1425        assert_eq!(names, serde_json::json!(["helper"]));
1426    }
1427
1428    // ---- resolution-gated impact (#207) ------------------------------------
1429
1430    #[tokio::test]
1431    async fn impact_returns_a_report_with_paths() {
1432        let s = seeded_server().await;
1433        let v: Value = serde_json::from_str(&result_text(
1434            &s.dispatch("impact", args("r", "main", "helper"))
1435                .await
1436                .unwrap(),
1437        ))
1438        .unwrap();
1439        // Not a bare name list any more: every node carries its defining path,
1440        // and the honesty fields ride alongside.
1441        assert_eq!(v["reached"][0]["name"], "main");
1442        assert_eq!(v["reached"][0]["path"], "main.rs");
1443        assert_eq!(v["ambiguous_edges"], 0);
1444        assert_eq!(v["truncated"], false);
1445    }
1446
1447    #[tokio::test]
1448    async fn impact_does_not_merge_subgraphs_through_a_shared_name() {
1449        // `helper` is defined in both files; nothing else is shared. The old
1450        // name-matched closure reached `top_b` from `leaf_a` (#207).
1451        let s = seeded_with(&[
1452            (
1453                "a.rs",
1454                "fn leaf_a() {}\nfn helper() { leaf_a(); }\nfn top_a() { helper(); }",
1455            ),
1456            (
1457                "b.rs",
1458                "fn leaf_b() {}\nfn helper() { leaf_b(); }\nfn top_b() { helper(); }",
1459            ),
1460        ])
1461        .await;
1462        let v: Value = serde_json::from_str(&result_text(
1463            &s.dispatch("impact", args("r", "main", "leaf_a"))
1464                .await
1465                .unwrap(),
1466        ))
1467        .unwrap();
1468        let names: Vec<&str> = v["reached"]
1469            .as_array()
1470            .unwrap()
1471            .iter()
1472            .map(|n| n["name"].as_str().unwrap())
1473            .collect();
1474        assert!(names.contains(&"top_a"), "{names:?}");
1475        assert!(!names.contains(&"top_b"), "must not cross files: {names:?}");
1476    }
1477
1478    #[tokio::test]
1479    async fn impact_accepts_a_max_depth() {
1480        let s = seeded_with(&[("a.rs", "fn l() {}\nfn m() { l(); }\nfn t() { m(); }")]).await;
1481        let mut a = args("r", "main", "l").unwrap();
1482        a.insert("max_depth".into(), serde_json::json!(1));
1483        let v: Value =
1484            serde_json::from_str(&result_text(&s.dispatch("impact", Some(a)).await.unwrap()))
1485                .unwrap();
1486        assert_eq!(v["reached"].as_array().unwrap().len(), 1);
1487        assert_eq!(v["reached"][0]["name"], "m");
1488        assert_eq!(v["truncated"], true);
1489    }
1490
1491    #[tokio::test]
1492    async fn impact_rejects_a_non_integer_max_depth() {
1493        let s = seeded_server().await;
1494        let mut a = args("r", "main", "helper").unwrap();
1495        a.insert("max_depth".into(), serde_json::json!("deep"));
1496        let result = s.dispatch("impact", Some(a)).await.unwrap();
1497        assert_eq!(result.is_error, Some(true));
1498        assert!(result_text(&result).contains("max_depth"));
1499    }
1500
1501    #[tokio::test]
1502    async fn node_aggregates_definitions_callers_and_callees() {
1503        let s = seeded_server().await;
1504        let result = s
1505            .dispatch("node", args("r", "main", "helper"))
1506            .await
1507            .unwrap();
1508        let node: Value = serde_json::from_str(&result_text(&result)).unwrap();
1509        assert_eq!(node["definitions"][0]["path"], "lib.rs");
1510        assert_eq!(node["callers"], serde_json::json!(["main"]));
1511        assert_eq!(node["callees"], serde_json::json!([]));
1512    }
1513
1514    #[tokio::test]
1515    async fn missing_arguments_are_a_tool_error() {
1516        let s = seeded_server().await;
1517        // No `name` provided.
1518        let bad = Some(
1519            serde_json::json!({ "repo": "r", "view_id": "main" })
1520                .as_object()
1521                .unwrap()
1522                .clone(),
1523        );
1524        let result = s.dispatch("search", bad).await.unwrap();
1525        assert_eq!(result.is_error, Some(true));
1526        assert!(result_text(&result).contains("name"));
1527    }
1528
1529    #[tokio::test]
1530    async fn dispatch_unknown_tool_is_method_not_found() {
1531        let s = server("/r");
1532        assert!(s.dispatch("no_such_tool", None).await.is_err());
1533    }
1534}