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_core::{
28    Body, DeleteResult, Identity, KeyPrefix, Meta, PutResult, Record, RecordKey, RecordKind,
29    Revision,
30};
31use gonzalo_graph::{Ranking, SymbolFilter, SymbolKind};
32use gonzalo_server::Service;
33use rmcp::handler::server::ServerHandler;
34use rmcp::model::{
35    CallToolRequestMethod, CallToolRequestParams, CallToolResult, Content, ListToolsResult,
36    PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
37};
38use rmcp::service::{RequestContext, RoleServer};
39use serde::Serialize;
40use serde_json::{Map, Value};
41use std::sync::Arc;
42
43/// Most defining paths listed beside a call-graph answer. Past this the list is
44/// noise rather than a lead, and `definition_count` alone carries the warning.
45const MAX_DEFINED_IN: usize = 10;
46
47/// An MCP server backed by a gonzalo [`Service`].
48#[derive(Clone)]
49pub struct GonzaloMcp {
50    service: Service,
51    root: String,
52    /// Whether the record write tools (`record_put`, `record_delete`) are
53    /// exposed. Off by default: reading a store is one thing, mutating it is
54    /// another, and that decision belongs to whoever runs the server rather
55    /// than to the agent talking to it (#197).
56    writes: bool,
57}
58
59impl GonzaloMcp {
60    /// Build a server over `service`, reporting `root` (the store location) in
61    /// `status`.
62    pub fn new(service: Service, root: impl Into<String>) -> Self {
63        Self {
64            service,
65            root: root.into(),
66            writes: false,
67        }
68    }
69
70    /// Expose the record write tools. The advertised tool list changes with it,
71    /// so an agent never sees a tool it would not be allowed to call.
72    pub fn with_writes(mut self, writes: bool) -> Self {
73        self.writes = writes;
74        self
75    }
76
77    /// Whether this server exposes the record write tools.
78    pub fn writes_enabled(&self) -> bool {
79        self.writes
80    }
81
82    /// The backing service (used by the D1b graph tools).
83    pub fn service(&self) -> &Service {
84        &self.service
85    }
86
87    /// The tools this server advertises: a view-independent `status`, the
88    /// per-symbol code-graph queries, and the whole-view aggregates — all the
89    /// graph tools taking a `(repo, view_id)` view selector.
90    pub fn tools(writes: bool) -> Vec<Tool> {
91        let mut tools = vec![
92            Tool::new(
93                "status",
94                "Report that the gonzalo-mcp server is up and its configured store root.",
95                status_schema(),
96            ),
97            Tool::new(
98                "search",
99                "Find where a symbol `name` is defined in a view. Returns located definitions.",
100                view_query_schema(),
101            ),
102            Tool::new(
103                "node",
104                "Inspect a symbol: its definitions, its callers, and its callees in a view.",
105                view_query_schema(),
106            ),
107            Tool::new(
108                "callers",
109                "The enclosing functions that call `name` in a view, in `callers`. This is a raw \
110                 name match, so read `definition_count` with it: above 1 means `name` is defined \
111                 in several places and the list merges callers of all of them, which is not the \
112                 same question you asked. `defined_in` names those files while there are few \
113                 enough to be useful. A count of 0 means the name is defined nowhere in the view, \
114                 which is what makes an empty list readable. `callers` is functions only; a call \
115                 sitting in a module-level declaration (a Zod schema, a tRPC handler) appears in \
116                 `module_callers` instead, so the two questions stay separable.",
117                view_query_schema(),
118            ),
119            Tool::new(
120                "callees",
121                "The names called from within `name` in a view, in `callees`. Like `callers` this \
122                 is a raw name match, so `definition_count` above 1 means several functions share \
123                 the name and the list merges what all of them call. `defined_in` names those \
124                 files while the count is small.",
125                view_query_schema(),
126            ),
127            Tool::new(
128                "impact",
129                "Every symbol transitively affected if `name` changes (the caller closure). Only \
130                 call edges that resolve to a specific definition are followed, so the walk does \
131                 not merge unrelated code that happens to share an identifier; edges that cannot \
132                 be attributed are counted in `ambiguous_edges` rather than traversed, and a \
133                 non-zero count means the true set may be larger. A function handed to a call \
134                 rather than called (`register(handler)`) is a real dependency but too \
135                 over-inclusive to walk, so it is counted in `value_edges` instead — non-zero \
136                 there means go look. Each result carries the path defining it. `truncated` means \
137                 the walk stopped at `max_depth` with frontier left, so completeness is unknown. Note that only calls are edges: `impact` on a \
138                 struct, trait, or type is empty because type usage is not recorded, which means \
139                 \"not applicable\" rather than \"nothing depends on it\".",
140                impact_schema(),
141            ),
142            Tool::new(
143                "explore",
144                "List references to `name` in a view (with their paths), for navigating outward.",
145                view_query_schema(),
146            ),
147            Tool::new(
148                "diff",
149                "Structural diff between two views of a repo: symbols and references added/removed \
150                 going from `view_a` to `view_b`.",
151                diff_schema(),
152            ),
153            Tool::new(
154                "overview",
155                "Summarize a whole view without needing a symbol name: file/symbol/reference \
156                 counts, a breakdown by kind and language, and the largest files. Start here when \
157                 orienting in an unfamiliar repo.",
158                overview_schema(),
159            ),
160            Tool::new(
161                "top",
162                "Rank a view's symbols: `fan_in` (most referenced), `fan_out` (calls the most \
163                 names), or `definitions` (defined in the most places — a score above 1 means the \
164                 name is ambiguous and traversals through it are unreliable).",
165                top_schema(),
166            ),
167            Tool::new(
168                "list",
169                "Enumerate a view's symbols, optionally filtered by path prefix, kind, and name \
170                 substring. Answers \"what is in this crate\" rather than \"where is this name\".",
171                list_schema(),
172            ),
173            Tool::new(
174                "views",
175                "List every indexed view as (repo, view_id) with its file count and the commit it \
176                 was indexed at. Call this first: `repo` and `view_id` must match a view produced \
177                 by `gonzalo index`, and this is the only way to discover the valid values. \
178                 Compare `base_commit` against the checkout's HEAD to spot a stale view.",
179                status_schema(),
180            ),
181            Tool::new(
182                "unreferenced",
183                "Symbols with no inbound reference — dead-code CANDIDATES, not dead code. This is \
184                 a heuristic over a name-matched graph and it does produce false positives. A \
185                 function used only as a value (higher-order usage, e.g. `map_err(be)`) now counts \
186                 as referenced and is no longer reported wrongly, but an unused name is still \
187                 hidden by any same-named symbol that is used. References from tests and from the \
188                 symbol itself do count, so test-only and recursive-only functions are never \
189                 reported. Confirm every hit against the source before acting on it.",
190                unreferenced_schema(),
191            ),
192            Tool::new(
193                "record_get",
194                "Read one record by (namespace, collection, id). Returns the whole record: body, \
195                 kind, revision and provenance. A key that holds nothing — or holds a deleted \
196                 record — is an error naming the key, never an empty result.",
197                record_key_schema(),
198            ),
199            Tool::new(
200                "record_list",
201                "List record keys in a namespace, or in one collection of it. Deleted records are \
202                 not listed. Returns keys only; read one with `record_get`.",
203                record_list_schema(),
204            ),
205        ];
206        if writes {
207            tools.push(Tool::new(
208                "record_put",
209                "Write a record at (namespace, collection, id). Without `expected_revision` this \
210                 creates: it succeeds only if the key holds nothing, so it cannot silently \
211                 overwrite. To update, pass the `revision` that `record_get` returned. A write the \
212                 store refuses because the key moved on comes back as `committed: false` with the \
213                 record that is actually there — an outcome to act on, not an error.",
214                record_put_schema(),
215            ));
216            tools.push(Tool::new(
217                "record_delete",
218                "Delete the record at (namespace, collection, id). The delete replicates: it \
219                 leaves a tombstone that sync carries to other stores, so it is not local. \
220                 Deleting a key that holds nothing succeeds and changes nothing.",
221                record_key_schema(),
222            ));
223        }
224        tools
225    }
226
227    /// The `status` payload: server health, the configured store root, and how
228    /// many views are indexed.
229    ///
230    /// The view count is the point (#210): `status` is the tool an agent reaches
231    /// for to self-check, and reporting only `ok` meant a server pointed at an
232    /// empty or wrong store looked perfectly healthy.
233    pub async fn status_json(&self) -> serde_json::Value {
234        match self.service.graph_views().await {
235            Ok(views) => serde_json::json!({
236                "status": "ok",
237                "root": self.root,
238                "views": views.len(),
239            }),
240            Err(e) => serde_json::json!({
241                "status": "degraded",
242                "root": self.root,
243                "error": e.to_string(),
244            }),
245        }
246    }
247
248    /// Dispatch a tool call by name, independent of the rmcp transport so it can
249    /// be unit-tested. Bad arguments and store errors surface as a tool error
250    /// (`CallToolResult::error`); an unknown tool is a `method_not_found` error.
251    pub async fn dispatch(
252        &self,
253        name: &str,
254        arguments: Option<Map<String, Value>>,
255    ) -> Result<CallToolResult, rmcp::ErrorData> {
256        // `status` takes no view selector.
257        if name == "status" {
258            return Ok(CallToolResult::success(vec![Content::text(
259                self.status_json().await.to_string(),
260            )]));
261        }
262
263        // `views` is the discovery tool — it takes no selector by definition.
264        if name == "views" {
265            return self.result(self.service.graph_views().await);
266        }
267
268        // `diff` selects two views instead of one view + a name.
269        if name == "diff" {
270            let (repo, view_a, view_b) = match diff_args(&arguments) {
271                Ok(t) => t,
272                Err(msg) => return Ok(tool_error(msg)),
273            };
274            for view in [&view_a, &view_b] {
275                if let Some(err) = self.unknown_view_error(&repo, view).await {
276                    return Ok(err);
277                }
278            }
279            return self.result(self.service.graph_diff(&repo, &view_a, &view_b).await);
280        }
281
282        // The aggregate tools select a view but take no symbol name.
283        if matches!(name, "overview" | "top" | "list" | "unreferenced") {
284            let (repo, view) = match selector_args(&arguments) {
285                Ok(t) => t,
286                Err(msg) => return Ok(tool_error(msg)),
287            };
288            if let Some(err) = self.unknown_view_error(&repo, &view).await {
289                return Ok(err);
290            }
291            return self.aggregate(name, &repo, &view, &arguments).await;
292        }
293
294        // The record tools address a store key rather than a view (#197). The
295        // write pair is dispatched only when this server was started with
296        // writes enabled; otherwise it falls through to `method_not_found`,
297        // which is what an unadvertised tool should be.
298        if matches!(name, "record_get" | "record_list")
299            || (self.writes && matches!(name, "record_put" | "record_delete"))
300        {
301            return self.record_tool(name, &arguments).await;
302        }
303
304        // An unknown tool is `method_not_found` regardless of arguments — decided
305        // before parsing the view selector so it isn't masked by a missing-arg
306        // error.
307        if !matches!(
308            name,
309            "search" | "node" | "callers" | "callees" | "impact" | "explore"
310        ) {
311            return Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>());
312        }
313
314        // Every graph tool selects a view by (repo, view_id) and a `name`.
315        let (repo, view, sym) = match view_args(&arguments) {
316            Ok(t) => t,
317            Err(msg) => return Ok(tool_error(msg)),
318        };
319
320        // An unresolvable selector is an error, never an empty result (#210):
321        // otherwise a typo in `view_id` reads as "nothing calls this".
322        if let Some(err) = self.unknown_view_error(&repo, &view).await {
323            return Ok(err);
324        }
325
326        match name {
327            "search" => self.result(self.service.graph_definitions(&repo, &view, &sym).await),
328            "callers" | "callees" => self.call_graph(&repo, &view, &sym, name).await,
329            "impact" => {
330                let max_depth = match usize_arg(&arguments, "max_depth", 0) {
331                    Ok(0) => None,
332                    Ok(n) => Some(n),
333                    Err(msg) => return Ok(tool_error(msg)),
334                };
335                self.result(
336                    self.service
337                        .graph_impact(&repo, &view, &sym, max_depth)
338                        .await,
339                )
340            }
341            "explore" => self.result(self.service.graph_references_to(&repo, &view, &sym).await),
342            "node" => self.node(&repo, &view, &sym).await,
343            // Unreachable: the known-tool guard above already returned for any
344            // other name.
345            _ => Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>()),
346        }
347    }
348
349    /// The record tools: read, list, write and delete records by key (#197).
350    ///
351    /// These answer from the `Store` rather than the code graph, so they work
352    /// against whatever the server was pointed at — a local fs root or a running
353    /// daemon.
354    async fn record_tool(
355        &self,
356        name: &str,
357        arguments: &Option<Map<String, Value>>,
358    ) -> Result<CallToolResult, rmcp::ErrorData> {
359        match name {
360            "record_list" => {
361                let namespace = match str_arg(arguments, "namespace") {
362                    Ok(ns) => ns,
363                    Err(msg) => return Ok(tool_error(msg)),
364                };
365                let collection = arguments
366                    .as_ref()
367                    .and_then(|m| m.get("collection"))
368                    .and_then(Value::as_str)
369                    .map(str::to_string);
370                let prefix = KeyPrefix {
371                    namespace: Some(namespace),
372                    collection,
373                };
374                match self.service.list(&prefix).await {
375                    // Keys as strings: `namespace/collection/id` is the form the
376                    // rest of the surface prints, and it round-trips into the
377                    // other record tools' three arguments.
378                    Ok(keys) => {
379                        let keys: Vec<String> = keys.iter().map(RecordKey::to_string).collect();
380                        self.result(Ok(serde_json::json!({ "keys": keys })))
381                    }
382                    Err(e) => Ok(tool_error(e.to_string())),
383                }
384            }
385            "record_get" => {
386                let key = match record_key_args(arguments) {
387                    Ok(k) => k,
388                    Err(msg) => return Ok(tool_error(msg)),
389                };
390                match self.service.get(&key).await {
391                    // Absent is an error, not an empty answer, for the same
392                    // reason an unknown view is (#210): an agent reads nothing
393                    // as "there is nothing there" rather than "you asked wrong".
394                    Ok(None) => Ok(tool_error(format!("no record at {key}"))),
395                    Ok(Some(record)) => self.result(Ok(record)),
396                    Err(e) => Ok(tool_error(e.to_string())),
397                }
398            }
399            "record_put" => {
400                let key = match record_key_args(arguments) {
401                    Ok(k) => k,
402                    Err(msg) => return Ok(tool_error(msg)),
403                };
404                let body = match str_arg(arguments, "body") {
405                    Ok(b) => b,
406                    Err(msg) => return Ok(tool_error(msg)),
407                };
408                let kind = match kind_arg(arguments) {
409                    Ok(k) => k,
410                    Err(msg) => return Ok(tool_error(msg)),
411                };
412                let expected = match expected_revision_arg(arguments) {
413                    Ok(rev) => rev,
414                    Err(msg) => return Ok(tool_error(msg)),
415                };
416                let meta = Meta::new(Identity::new("gonzalo-mcp"), "mcp");
417                let record = match &expected {
418                    // An update follows the revision the caller read, so the
419                    // parent chain stays intact; a create starts a new one.
420                    Some(rev) => Record {
421                        parent: Some(rev.clone()),
422                        ..Record::create(key.clone(), kind, Body::Inline(body.into_bytes()), meta)
423                    },
424                    None => {
425                        Record::create(key.clone(), kind, Body::Inline(body.into_bytes()), meta)
426                    }
427                };
428                match self.service.put(record, expected).await {
429                    // A conflict is a normal OCC outcome, so it is data the
430                    // agent can act on — the current record comes back with it.
431                    Ok(PutResult::Committed(revision)) => self.result(Ok(serde_json::json!({
432                        "committed": true,
433                        "key": key.to_string(),
434                        "revision": revision,
435                    }))),
436                    Ok(PutResult::Conflict(conflict)) => self.result(Ok(serde_json::json!({
437                        "committed": false,
438                        "key": key.to_string(),
439                        "reason": "the key moved on since the revision this write expected",
440                        "current": conflict.current,
441                    }))),
442                    Err(e) => Ok(tool_error(e.to_string())),
443                }
444            }
445            "record_delete" => {
446                let key = match record_key_args(arguments) {
447                    Ok(k) => k,
448                    Err(msg) => return Ok(tool_error(msg)),
449                };
450                match self.service.delete_as(&key, None, None).await {
451                    Ok(DeleteResult::Deleted) => self.result(Ok(serde_json::json!({
452                        "deleted": true,
453                        "key": key.to_string(),
454                    }))),
455                    Ok(DeleteResult::Conflict(conflict)) => self.result(Ok(serde_json::json!({
456                        "deleted": false,
457                        "key": key.to_string(),
458                        "current": conflict.current,
459                    }))),
460                    Err(e) => Ok(tool_error(e.to_string())),
461                }
462            }
463            // Unreachable: the caller matched the name before dispatching here.
464            _ => Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>()),
465        }
466    }
467
468    /// A tool error when `(repo, view_id)` names no indexed view, or `None` when
469    /// the selector resolves.
470    ///
471    /// The message names the unresolved selector *and* lists the views that do
472    /// exist, so a caller that guessed wrong can correct itself in one round
473    /// trip instead of concluding the code is not there (#210).
474    async fn unknown_view_error(&self, repo: &str, view: &str) -> Option<CallToolResult> {
475        match self.service.view_exists(repo, view).await {
476            Ok(true) => None,
477            Ok(false) => {
478                let known = match self.service.graph_views().await {
479                    Ok(views) if !views.is_empty() => views
480                        .iter()
481                        .map(|v| format!("{}/{}", v.repo, v.view_id))
482                        .collect::<Vec<_>>()
483                        .join(", "),
484                    Ok(_) => "none — run `gonzalo index` first".to_string(),
485                    Err(e) => format!("<could not list views: {e}>"),
486                };
487                Some(tool_error(format!(
488                    "no indexed view '{repo}/{view}'. This is a selector error, not an empty \
489                     result. Indexed views: {known}. Call `views` to list them."
490                )))
491            }
492            // A store failure is reported as itself rather than as "no view".
493            Err(e) => Some(tool_error(e.to_string())),
494        }
495    }
496
497    /// Turn a service query outcome into a tool result: JSON on success, a tool
498    /// error carrying the message on failure.
499    fn result<T: Serialize>(
500        &self,
501        outcome: gonzalo_core::Result<T>,
502    ) -> Result<CallToolResult, rmcp::ErrorData> {
503        match outcome {
504            Ok(value) => Ok(success_json(&value)),
505            Err(e) => Ok(tool_error(e.to_string())),
506        }
507    }
508
509    /// Dispatch the view-wide aggregate tools, which take a `(repo, view_id)`
510    /// selector plus their own optional arguments rather than a symbol name.
511    async fn aggregate(
512        &self,
513        name: &str,
514        repo: &str,
515        view: &str,
516        arguments: &Option<Map<String, Value>>,
517    ) -> Result<CallToolResult, rmcp::ErrorData> {
518        match name {
519            "overview" => {
520                let largest = match usize_arg(arguments, "largest", DEFAULT_TOP_LIMIT) {
521                    Ok(n) => n,
522                    Err(msg) => return Ok(tool_error(msg)),
523                };
524                self.result(self.service.graph_overview(repo, view, largest).await)
525            }
526            "top" => {
527                let ranking = match ranking_arg(arguments) {
528                    Ok(r) => r,
529                    Err(msg) => return Ok(tool_error(msg)),
530                };
531                let limit = match usize_arg(arguments, "limit", DEFAULT_TOP_LIMIT) {
532                    Ok(n) => n,
533                    Err(msg) => return Ok(tool_error(msg)),
534                };
535                self.result(self.service.graph_top(repo, view, ranking, limit).await)
536            }
537            "list" => {
538                let filter = match filter_args(arguments) {
539                    Ok(f) => f,
540                    Err(msg) => return Ok(tool_error(msg)),
541                };
542                let limit = match usize_arg(arguments, "limit", DEFAULT_LIST_LIMIT) {
543                    Ok(n) => n,
544                    Err(msg) => return Ok(tool_error(msg)),
545                };
546                self.result(self.service.graph_list(repo, view, &filter, limit).await)
547            }
548            "unreferenced" => {
549                let filter = match filter_args(arguments) {
550                    Ok(f) => f,
551                    Err(msg) => return Ok(tool_error(msg)),
552                };
553                let exclude_tests = match bool_arg(arguments, "exclude_tests", true) {
554                    Ok(b) => b,
555                    Err(msg) => return Ok(tool_error(msg)),
556                };
557                let limit = match usize_arg(arguments, "limit", DEFAULT_LIST_LIMIT) {
558                    Ok(n) => n,
559                    Err(msg) => return Ok(tool_error(msg)),
560                };
561                self.result(
562                    self.service
563                        .graph_unreferenced(repo, view, &filter, exclude_tests, limit)
564                        .await,
565                )
566            }
567            // Unreachable: the caller already matched on these four names.
568            _ => Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>()),
569        }
570    }
571
572    /// `callers`/`callees`, answered with how many definitions the queried name
573    /// has.
574    ///
575    /// These are raw name matches: a name defined in several places returns the
576    /// merged answer for all of them, and a bare list says nothing about that.
577    /// It is the point where a caller is most likely to read a heuristic result
578    /// as a precise one, so the count travels with the answer (#249). The
579    /// defining paths come too while there are few enough to be a lead rather
580    /// than noise.
581    async fn call_graph(
582        &self,
583        repo: &str,
584        view: &str,
585        sym: &str,
586        tool: &str,
587    ) -> Result<CallToolResult, rmcp::ErrorData> {
588        // `callers` splits by what the caller *is*: a function, or a
589        // module-level binding such as a Zod schema or a tRPC handler. Kept as
590        // two fields rather than one list of objects so `callers` means exactly
591        // what it meant before — functions that call this (#268).
592        let (names, module_names) = if tool == "callers" {
593            match self.service.graph_callers_scoped(repo, view, sym).await {
594                Ok(scoped) => {
595                    let (functions, modules): (Vec<_>, Vec<_>) = scoped
596                        .into_iter()
597                        .partition(|(_, scope)| scope.is_function());
598                    (
599                        functions.into_iter().map(|(name, _)| name).collect(),
600                        Some(
601                            modules
602                                .into_iter()
603                                .map(|(name, _)| name)
604                                .collect::<Vec<_>>(),
605                        ),
606                    )
607                }
608                Err(e) => return Ok(tool_error(e.to_string())),
609            }
610        } else {
611            match self.service.graph_callees(repo, view, sym).await {
612                Ok(names) => (names, None),
613                Err(e) => return Ok(tool_error(e.to_string())),
614            }
615        };
616        let defs = match self.service.graph_definitions(repo, view, sym).await {
617            Ok(d) => d,
618            Err(e) => return Ok(tool_error(e.to_string())),
619        };
620
621        let mut paths: Vec<&str> = defs.iter().map(|d| d.path.as_str()).collect();
622        paths.sort_unstable();
623        paths.dedup();
624
625        let mut payload = serde_json::json!({
626            tool: names,
627            "definition_count": defs.len(),
628        });
629        if let Some(module_names) = module_names {
630            payload["module_callers"] = serde_json::json!(module_names);
631        }
632        if paths.len() <= MAX_DEFINED_IN {
633            payload["defined_in"] = serde_json::json!(paths);
634        }
635        Ok(success_json(&payload))
636    }
637
638    /// The `node` aggregate: definitions + callers + callees for one symbol.
639    async fn node(
640        &self,
641        repo: &str,
642        view: &str,
643        sym: &str,
644    ) -> Result<CallToolResult, rmcp::ErrorData> {
645        let defs = match self.service.graph_definitions(repo, view, sym).await {
646            Ok(d) => d,
647            Err(e) => return Ok(tool_error(e.to_string())),
648        };
649        let callers = match self.service.graph_callers_of(repo, view, sym).await {
650            Ok(c) => c,
651            Err(e) => return Ok(tool_error(e.to_string())),
652        };
653        let callees = match self.service.graph_callees(repo, view, sym).await {
654            Ok(c) => c,
655            Err(e) => return Ok(tool_error(e.to_string())),
656        };
657        let payload = serde_json::json!({
658            "definitions": defs,
659            "callers": callers,
660            "callees": callees,
661        });
662        Ok(success_json(&payload))
663    }
664}
665
666/// Input schema for the view queries: `repo`, `view_id`, and `name` — all
667/// required strings.
668fn view_query_schema() -> Arc<Map<String, Value>> {
669    let schema = serde_json::json!({
670        "type": "object",
671        "properties": {
672            "repo": {
673                "type": "string",
674                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
675                                reported by `views`, not an arbitrary name"
676            },
677            "view_id": {
678                "type": "string",
679                "description": "view id of an INDEXED view, e.g. main — must match one reported \
680                                by `views`"
681            },
682            "name": { "type": "string", "description": "symbol name to query" }
683        },
684        "required": ["repo", "view_id", "name"],
685        "additionalProperties": false
686    });
687    Arc::new(schema.as_object().expect("object schema").clone())
688}
689
690/// Input schema for `diff`: `repo`, `view_a`, and `view_b` — all required.
691fn diff_schema() -> Arc<Map<String, Value>> {
692    let schema = serde_json::json!({
693        "type": "object",
694        "properties": {
695            "repo": {
696                "type": "string",
697                "description": "repository of two INDEXED views — must match `views` output"
698            },
699            "view_a": { "type": "string", "description": "the base view id (must be indexed)" },
700            "view_b": {
701                "type": "string",
702                "description": "the view id to compare against the base (must be indexed)"
703            }
704        },
705        "required": ["repo", "view_a", "view_b"],
706        "additionalProperties": false
707    });
708    Arc::new(schema.as_object().expect("object schema").clone())
709}
710
711/// Default number of entries returned by `overview.largest_files` and `top`.
712const DEFAULT_TOP_LIMIT: usize = 20;
713/// Default number of symbols returned by `list`.
714const DEFAULT_LIST_LIMIT: usize = 100;
715
716/// Input schema for `overview`: a view selector plus an optional cap on the
717/// `largest_files` listing.
718fn overview_schema() -> Arc<Map<String, Value>> {
719    let schema = serde_json::json!({
720        "type": "object",
721        "properties": {
722            "repo": {
723                "type": "string",
724                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
725                                reported by `views`, not an arbitrary name"
726            },
727            "view_id": {
728                "type": "string",
729                "description": "view id of an INDEXED view, e.g. main — must match one reported \
730                                by `views`"
731            },
732            "largest": {
733                "type": "integer",
734                "minimum": 0,
735                "description": "how many of the largest files to list (default 20); the counts \
736                                themselves are never truncated"
737            }
738        },
739        "required": ["repo", "view_id"],
740        "additionalProperties": false
741    });
742    Arc::new(schema.as_object().expect("object schema").clone())
743}
744
745/// Input schema for `top`: a view selector, the required ranking, and a limit.
746fn top_schema() -> Arc<Map<String, Value>> {
747    let schema = serde_json::json!({
748        "type": "object",
749        "properties": {
750            "repo": {
751                "type": "string",
752                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
753                                reported by `views`, not an arbitrary name"
754            },
755            "view_id": {
756                "type": "string",
757                "description": "view id of an INDEXED view, e.g. main — must match one reported \
758                                by `views`"
759            },
760            "by": {
761                "type": "string",
762                "enum": ["fan_in", "fan_out", "definitions"],
763                "description": "fan_in = most referenced; fan_out = calls the most distinct \
764                                names; definitions = defined in the most paths (>1 is ambiguous)"
765            },
766            "limit": {
767                "type": "integer",
768                "minimum": 0,
769                "description": "maximum entries to return (default 20)"
770            }
771        },
772        "required": ["repo", "view_id", "by"],
773        "additionalProperties": false
774    });
775    Arc::new(schema.as_object().expect("object schema").clone())
776}
777
778/// Input schema for `list`: a view selector plus conjunctive filters.
779fn list_schema() -> Arc<Map<String, Value>> {
780    let schema = serde_json::json!({
781        "type": "object",
782        "properties": {
783            "repo": {
784                "type": "string",
785                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
786                                reported by `views`, not an arbitrary name"
787            },
788            "view_id": {
789                "type": "string",
790                "description": "view id of an INDEXED view, e.g. main — must match one reported \
791                                by `views`"
792            },
793            "path_prefix": {
794                "type": "string",
795                "description": "only symbols whose path starts with this (scopes to a crate or \
796                                directory)"
797            },
798            "kind": {
799                "type": "string",
800                "enum": [
801                    "function", "struct", "enum", "trait", "impl", "module",
802                    "const", "static", "type_alias", "class", "interface"
803                ],
804                "description": "only symbols of this kind"
805            },
806            "name_contains": {
807                "type": "string",
808                "description": "only symbols whose name contains this substring"
809            },
810            "limit": {
811                "type": "integer",
812                "minimum": 0,
813                "description": "maximum symbols to return (default 100)"
814            }
815        },
816        "required": ["repo", "view_id"],
817        "additionalProperties": false
818    });
819    Arc::new(schema.as_object().expect("object schema").clone())
820}
821
822/// Input schema for `unreferenced`: the `list` filters plus the test-scope
823/// toggle.
824fn unreferenced_schema() -> Arc<Map<String, Value>> {
825    let schema = serde_json::json!({
826        "type": "object",
827        "properties": {
828            "repo": {
829                "type": "string",
830                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
831                                reported by `views`, not an arbitrary name"
832            },
833            "view_id": {
834                "type": "string",
835                "description": "view id of an INDEXED view, e.g. main — must match one reported \
836                                by `views`"
837            },
838            "path_prefix": {
839                "type": "string",
840                "description": "only symbols whose path starts with this (scopes to a crate or \
841                                directory)"
842            },
843            "kind": {
844                "type": "string",
845                "enum": [
846                    "function", "struct", "enum", "trait", "impl", "module",
847                    "const", "static", "type_alias", "class", "interface"
848                ],
849                "description": "only symbols of this kind; `function` is usually what you want"
850            },
851            "name_contains": {
852                "type": "string",
853                "description": "only symbols whose name contains this substring"
854            },
855            "exclude_tests": {
856                "type": "boolean",
857                "description": "drop symbols inside a `mod tests`/`mod test` block or under a \
858                                tests/ directory (default true); without it the result is mostly \
859                                test helpers"
860            },
861            "limit": {
862                "type": "integer",
863                "minimum": 0,
864                "description": "maximum candidates to return (default 100)"
865            }
866        },
867        "required": ["repo", "view_id"],
868        "additionalProperties": false
869    });
870    Arc::new(schema.as_object().expect("object schema").clone())
871}
872
873/// Input schema for `impact`: the per-symbol selector plus an optional depth cap.
874fn impact_schema() -> Arc<Map<String, Value>> {
875    let schema = serde_json::json!({
876        "type": "object",
877        "properties": {
878            "repo": {
879                "type": "string",
880                "description": "repository of an INDEXED view, e.g. acme/widgets — must match one \
881                                reported by `views`, not an arbitrary name"
882            },
883            "view_id": {
884                "type": "string",
885                "description": "view id of an INDEXED view, e.g. main — must match one reported \
886                                by `views`"
887            },
888            "name": { "type": "string", "description": "symbol name to seed the closure from" },
889            "max_depth": {
890                "type": "integer",
891                "minimum": 0,
892                "description": "stop after this many hops (0 or omitted = unbounded); the result \
893                                reports `truncated` when a cap stopped it early"
894            }
895        },
896        "required": ["repo", "view_id", "name"],
897        "additionalProperties": false
898    });
899    Arc::new(schema.as_object().expect("object schema").clone())
900}
901
902/// A minimal object schema for the argument-free `status` tool.
903fn status_schema() -> Arc<Map<String, Value>> {
904    let schema = serde_json::json!({ "type": "object", "additionalProperties": false });
905    Arc::new(schema.as_object().expect("object schema").clone())
906}
907
908/// Input schema for the tools that address one record: the three key parts.
909fn record_key_schema() -> Arc<Map<String, Value>> {
910    let schema = serde_json::json!({
911        "type": "object",
912        "properties": {
913            "namespace": { "type": "string", "description": "record namespace, e.g. memory" },
914            "collection": { "type": "string", "description": "collection within the namespace, e.g. topics" },
915            "id": { "type": "string", "description": "record id within the collection" }
916        },
917        "required": ["namespace", "collection", "id"],
918        "additionalProperties": false
919    });
920    Arc::new(schema.as_object().expect("object schema").clone())
921}
922
923/// Input schema for `record_list`: a namespace, optionally narrowed to one
924/// collection.
925fn record_list_schema() -> Arc<Map<String, Value>> {
926    let schema = serde_json::json!({
927        "type": "object",
928        "properties": {
929            "namespace": { "type": "string", "description": "record namespace to list" },
930            "collection": {
931                "type": "string",
932                "description": "optional collection to narrow the listing to"
933            }
934        },
935        "required": ["namespace"],
936        "additionalProperties": false
937    });
938    Arc::new(schema.as_object().expect("object schema").clone())
939}
940
941/// Input schema for `record_put`: the key, the body, the kind, and the
942/// optimistic-concurrency revision.
943fn record_put_schema() -> Arc<Map<String, Value>> {
944    let schema = serde_json::json!({
945        "type": "object",
946        "properties": {
947            "namespace": { "type": "string", "description": "record namespace" },
948            "collection": { "type": "string", "description": "collection within the namespace" },
949            "id": { "type": "string", "description": "record id within the collection" },
950            "body": { "type": "string", "description": "the record body, stored verbatim" },
951            "kind": {
952                "type": "string",
953                "description": "record kind, exactly as `record_get` reports it. Defaults to \
954                                Topic. `Tombstone` is not writable — delete with `record_delete`.",
955                "enum": [
956                    "MemoryTier", "Topic", "Session", "Checkpoint", "Ticket", "TicketEvent",
957                    "GraphManifest", "Person", "IdentityBinding", "RoleGrant", "ChannelConfig",
958                    "LinkToken", "AuditEntry"
959                ]
960            },
961            "expected_revision": {
962                "type": "object",
963                "description": "the revision this write expects to replace — pass back the \
964                                `revision` object exactly as `record_get` returned it. Omit to \
965                                create a record that does not exist yet."
966            }
967        },
968        "required": ["namespace", "collection", "id", "body"],
969        "additionalProperties": false
970    });
971    Arc::new(schema.as_object().expect("object schema").clone())
972}
973
974/// The `(namespace, collection, id)` a record tool addresses.
975fn record_key_args(arguments: &Option<Map<String, Value>>) -> Result<RecordKey, String> {
976    Ok(RecordKey::new(
977        str_arg(arguments, "namespace")?,
978        str_arg(arguments, "collection")?,
979        str_arg(arguments, "id")?,
980    ))
981}
982
983/// The `kind` a `record_put` writes, defaulting to `topic`.
984///
985/// An unknown kind is refused rather than defaulted: silently storing a record
986/// under the wrong kind would change how it merges during sync, which is not
987/// something to guess at on the caller's behalf.
988fn kind_arg(arguments: &Option<Map<String, Value>>) -> Result<RecordKind, String> {
989    let Some(raw) = arguments
990        .as_ref()
991        .and_then(|m| m.get("kind"))
992        .and_then(Value::as_str)
993    else {
994        return Ok(RecordKind::Topic);
995    };
996    serde_json::from_value(Value::String(raw.to_string()))
997        .map_err(|_| format!("unknown record kind '{raw}'"))
998}
999
1000/// The optional `expected_revision` a `record_put` carries, as `record_get`
1001/// reported it. Absent means "create": the write applies only to an empty key.
1002fn expected_revision_arg(
1003    arguments: &Option<Map<String, Value>>,
1004) -> Result<Option<Revision>, String> {
1005    let Some(raw) = arguments.as_ref().and_then(|m| m.get("expected_revision")) else {
1006        return Ok(None);
1007    };
1008    if raw.is_null() {
1009        return Ok(None);
1010    }
1011    serde_json::from_value(raw.clone())
1012        .map(Some)
1013        .map_err(|e| format!("expected_revision is not a revision: {e}"))
1014}
1015
1016/// Extract a required string argument by key.
1017fn str_arg(arguments: &Option<Map<String, Value>>, key: &str) -> Result<String, String> {
1018    arguments
1019        .as_ref()
1020        .and_then(|m| m.get(key))
1021        .and_then(Value::as_str)
1022        .map(str::to_string)
1023        .ok_or_else(|| format!("missing required string argument '{key}'"))
1024}
1025
1026/// Extract the required `(repo, view_id, name)` view selector from tool args.
1027fn view_args(arguments: &Option<Map<String, Value>>) -> Result<(String, String, String), String> {
1028    Ok((
1029        str_arg(arguments, "repo")?,
1030        str_arg(arguments, "view_id")?,
1031        str_arg(arguments, "name")?,
1032    ))
1033}
1034
1035/// Extract the required `(repo, view_id)` selector, for tools that address a
1036/// whole view rather than a symbol in it.
1037fn selector_args(arguments: &Option<Map<String, Value>>) -> Result<(String, String), String> {
1038    Ok((str_arg(arguments, "repo")?, str_arg(arguments, "view_id")?))
1039}
1040
1041/// Extract an optional non-negative integer argument, or `default` if absent.
1042fn usize_arg(
1043    arguments: &Option<Map<String, Value>>,
1044    key: &str,
1045    default: usize,
1046) -> Result<usize, String> {
1047    match arguments.as_ref().and_then(|m| m.get(key)) {
1048        None | Some(Value::Null) => Ok(default),
1049        Some(v) => v
1050            .as_u64()
1051            .map(|n| n as usize)
1052            .ok_or_else(|| format!("argument '{key}' must be a non-negative integer, got {v}")),
1053    }
1054}
1055
1056/// Extract an optional boolean argument, or `default` if absent.
1057fn bool_arg(
1058    arguments: &Option<Map<String, Value>>,
1059    key: &str,
1060    default: bool,
1061) -> Result<bool, String> {
1062    match arguments.as_ref().and_then(|m| m.get(key)) {
1063        None | Some(Value::Null) => Ok(default),
1064        Some(Value::Bool(b)) => Ok(*b),
1065        Some(v) => Err(format!("argument '{key}' must be a boolean, got {v}")),
1066    }
1067}
1068
1069/// Extract the required `by` ranking for `top`.
1070fn ranking_arg(arguments: &Option<Map<String, Value>>) -> Result<Ranking, String> {
1071    let raw = str_arg(arguments, "by")?;
1072    match raw.as_str() {
1073        "fan_in" => Ok(Ranking::FanIn),
1074        "fan_out" => Ok(Ranking::FanOut),
1075        "definitions" => Ok(Ranking::Definitions),
1076        other => Err(format!(
1077            "unknown ranking '{other}': expected one of fan_in, fan_out, definitions"
1078        )),
1079    }
1080}
1081
1082/// Build the `list` filter from its optional arguments.
1083fn filter_args(arguments: &Option<Map<String, Value>>) -> Result<SymbolFilter, String> {
1084    let mut filter = SymbolFilter::default();
1085    if let Some(prefix) = opt_str_arg(arguments, "path_prefix")? {
1086        filter = filter.path_prefix(prefix);
1087    }
1088    if let Some(needle) = opt_str_arg(arguments, "name_contains")? {
1089        filter = filter.name_contains(needle);
1090    }
1091    if let Some(raw) = opt_str_arg(arguments, "kind")? {
1092        filter = filter.kind(parse_kind(&raw)?);
1093    }
1094    Ok(filter)
1095}
1096
1097/// Extract an optional string argument, erroring if present but not a string.
1098fn opt_str_arg(
1099    arguments: &Option<Map<String, Value>>,
1100    key: &str,
1101) -> Result<Option<String>, String> {
1102    match arguments.as_ref().and_then(|m| m.get(key)) {
1103        None | Some(Value::Null) => Ok(None),
1104        Some(Value::String(s)) => Ok(Some(s.clone())),
1105        Some(v) => Err(format!("argument '{key}' must be a string, got {v}")),
1106    }
1107}
1108
1109/// Parse a [`SymbolKind`] from its lowercase wire name.
1110fn parse_kind(raw: &str) -> Result<SymbolKind, String> {
1111    [
1112        SymbolKind::Function,
1113        SymbolKind::Struct,
1114        SymbolKind::Enum,
1115        SymbolKind::Trait,
1116        SymbolKind::Impl,
1117        SymbolKind::Module,
1118        SymbolKind::Const,
1119        SymbolKind::Static,
1120        SymbolKind::TypeAlias,
1121        SymbolKind::Class,
1122        SymbolKind::Interface,
1123    ]
1124    .into_iter()
1125    .find(|k| k.as_str() == raw)
1126    .ok_or_else(|| format!("unknown kind '{raw}'"))
1127}
1128
1129/// Extract the required `(repo, view_a, view_b)` selector for `diff`.
1130fn diff_args(arguments: &Option<Map<String, Value>>) -> Result<(String, String, String), String> {
1131    Ok((
1132        str_arg(arguments, "repo")?,
1133        str_arg(arguments, "view_a")?,
1134        str_arg(arguments, "view_b")?,
1135    ))
1136}
1137
1138/// A tool error carrying `msg` as text (`isError = true`).
1139fn tool_error(msg: impl Into<String>) -> CallToolResult {
1140    CallToolResult::error(vec![Content::text(msg.into())])
1141}
1142
1143/// A successful tool result whose single text block is `value` as JSON.
1144fn success_json<T: Serialize>(value: &T) -> CallToolResult {
1145    match serde_json::to_string(value) {
1146        Ok(json) => CallToolResult::success(vec![Content::text(json)]),
1147        Err(e) => tool_error(format!("failed to serialize result: {e}")),
1148    }
1149}
1150
1151impl ServerHandler for GonzaloMcp {
1152    fn get_info(&self) -> ServerInfo {
1153        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
1154            .with_instructions("gonzalo-mcp: code-graph queries over a gonzalo view")
1155    }
1156
1157    async fn list_tools(
1158        &self,
1159        _request: Option<PaginatedRequestParams>,
1160        _context: RequestContext<RoleServer>,
1161    ) -> Result<ListToolsResult, rmcp::ErrorData> {
1162        Ok(ListToolsResult::with_all_items(Self::tools(self.writes)))
1163    }
1164
1165    async fn call_tool(
1166        &self,
1167        request: CallToolRequestParams,
1168        _context: RequestContext<RoleServer>,
1169    ) -> Result<CallToolResult, rmcp::ErrorData> {
1170        self.dispatch(request.name.as_ref(), request.arguments)
1171            .await
1172    }
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::*;
1178    use gonzalo_core::{
1179        BlobStore, Identity, Manifest, Meta, PutResult, Record, RecordKind, Revision, Store,
1180    };
1181    use gonzalo_graph::build_rust;
1182    use gonzalo_store_fs::FsStore;
1183    use std::collections::BTreeMap;
1184
1185    fn server(root: &str) -> GonzaloMcp {
1186        let fs = Arc::new(FsStore::new(tempfile::tempdir().unwrap().keep()));
1187        GonzaloMcp::new(Service::new(fs.clone(), fs), root)
1188    }
1189
1190    /// A server whose store already holds view `r`/`main` with two slices.
1191    async fn seeded_server() -> GonzaloMcp {
1192        seeded_with(&[
1193            ("lib.rs", "fn helper() {}"),
1194            ("main.rs", "fn main() { helper(); }"),
1195        ])
1196        .await
1197    }
1198
1199    /// A server whose store holds view `r`/`main` assembled from `slices`.
1200    /// Like [`seeded_with`], for a language other than Rust.
1201    async fn seeded_with_language(
1202        language: gonzalo_graph::Language,
1203        slices: &[(&str, &str)],
1204    ) -> GonzaloMcp {
1205        seeded_from(slices, |src| gonzalo_graph::build(language, src)).await
1206    }
1207
1208    async fn seeded_with(slices: &[(&str, &str)]) -> GonzaloMcp {
1209        seeded_from(slices, build_rust).await
1210    }
1211
1212    async fn seeded_from(
1213        slices: &[(&str, &str)],
1214        parse: impl Fn(&str) -> gonzalo_graph::CodeGraph,
1215    ) -> GonzaloMcp {
1216        let fs = Arc::new(FsStore::new(tempfile::tempdir().unwrap().keep()));
1217        let mut manifest = Manifest::new();
1218        for &(path, src) in slices {
1219            let hash = fs.put_blob(&parse(src).to_slice_bytes()).await.unwrap();
1220            manifest.insert(path, hash);
1221        }
1222        let body = manifest.to_body();
1223        let record = Record {
1224            revision: Revision::initial(body.bytes()),
1225            parent: None,
1226            body,
1227            kind: RecordKind::GraphManifest,
1228            meta: Meta {
1229                author: Identity::new("tester"),
1230                origin_system: "test".into(),
1231                created: 0,
1232                updated: 0,
1233                labels: BTreeMap::new(),
1234            },
1235            links: Vec::new(),
1236            key: Manifest::key("r", "main"),
1237            ancestors: Vec::new(),
1238            deleted_at: None,
1239            deleted_blob: None,
1240            deleted_kind: None,
1241        };
1242        assert!(matches!(
1243            fs.put(record, None).await.unwrap(),
1244            PutResult::Committed(_)
1245        ));
1246        GonzaloMcp::new(Service::new(fs.clone(), fs), "test-root")
1247    }
1248
1249    fn args(repo: &str, view: &str, name: &str) -> Option<Map<String, Value>> {
1250        Some(
1251            serde_json::json!({ "repo": repo, "view_id": view, "name": name })
1252                .as_object()
1253                .unwrap()
1254                .clone(),
1255        )
1256    }
1257
1258    /// The text of a result's first content block (via the serialized result).
1259    fn result_text(result: &CallToolResult) -> String {
1260        let v = serde_json::to_value(result).unwrap();
1261        v["content"][0]["text"]
1262            .as_str()
1263            .expect("text content")
1264            .to_string()
1265    }
1266
1267    #[test]
1268    fn advertises_status_and_the_graph_tools() {
1269        let names: Vec<String> = GonzaloMcp::tools(false)
1270            .iter()
1271            .map(|t| t.name.to_string())
1272            .collect();
1273        assert_eq!(
1274            names,
1275            vec![
1276                "status",
1277                "search",
1278                "node",
1279                "callers",
1280                "callees",
1281                "impact",
1282                "explore",
1283                "diff",
1284                "overview",
1285                "top",
1286                "list",
1287                "views",
1288                "unreferenced",
1289                "record_get",
1290                "record_list",
1291            ]
1292        );
1293    }
1294
1295    // ---- aggregate / structural tools (#214) ------------------------------
1296
1297    /// `(repo, view_id)` plus whatever extra arguments a tool takes.
1298    fn view_args_with(repo: &str, view: &str, extra: Value) -> Option<Map<String, Value>> {
1299        let mut map = serde_json::json!({ "repo": repo, "view_id": view })
1300            .as_object()
1301            .unwrap()
1302            .clone();
1303        for (k, v) in extra.as_object().expect("object") {
1304            map.insert(k.clone(), v.clone());
1305        }
1306        Some(map)
1307    }
1308
1309    async fn call(tool: &str, extra: Value) -> Value {
1310        let s = seeded_server().await;
1311        let result = s
1312            .dispatch(tool, view_args_with("r", "main", extra))
1313            .await
1314            .unwrap();
1315        assert_ne!(result.is_error, Some(true), "tool {tool} errored");
1316        serde_json::from_str(&result_text(&result)).unwrap()
1317    }
1318
1319    #[tokio::test]
1320    async fn overview_tool_reports_the_shape_of_the_view() {
1321        // The seeded view is lib.rs (`helper`) + main.rs (`main` calling helper).
1322        let v = call("overview", serde_json::json!({})).await;
1323        assert_eq!(v["files"], 2);
1324        assert_eq!(v["symbols"], 2);
1325        assert_eq!(v["references"], 1);
1326        assert_eq!(v["by_kind"]["function"], 2);
1327        assert_eq!(v["by_language"]["rust"], 2);
1328        assert_eq!(v["largest_files"].as_array().unwrap().len(), 2);
1329    }
1330
1331    #[tokio::test]
1332    async fn overview_bounds_the_largest_files_listing() {
1333        let v = call("overview", serde_json::json!({ "largest": 1 })).await;
1334        assert_eq!(v["largest_files"].as_array().unwrap().len(), 1);
1335        assert_eq!(v["files"], 2, "the count itself is not truncated");
1336    }
1337
1338    #[tokio::test]
1339    async fn top_tool_ranks_by_fan_in() {
1340        let v = call("top", serde_json::json!({ "by": "fan_in" })).await;
1341        let helper = v["items"]
1342            .as_array()
1343            .unwrap()
1344            .iter()
1345            .find(|r| r["name"] == "helper")
1346            .expect("helper is referenced once");
1347        assert_eq!(helper["score"], 1);
1348        assert_eq!(helper["paths"], serde_json::json!(["lib.rs"]));
1349    }
1350
1351    #[tokio::test]
1352    async fn top_tool_ranks_by_definitions_for_the_ambiguity_report() {
1353        let v = call("top", serde_json::json!({ "by": "definitions" })).await;
1354        // Nothing is ambiguous in this view: every name has exactly one home.
1355        assert!(
1356            v["items"]
1357                .as_array()
1358                .unwrap()
1359                .iter()
1360                .all(|r| r["score"] == 1)
1361        );
1362    }
1363
1364    #[tokio::test]
1365    async fn top_tool_reports_truncation() {
1366        // Two names are defined in the seeded view (`helper`, `main`), so a
1367        // limit of 1 must report that something was left out.
1368        let v = call(
1369            "top",
1370            serde_json::json!({ "by": "definitions", "limit": 1 }),
1371        )
1372        .await;
1373        assert_eq!(v["items"].as_array().unwrap().len(), 1);
1374        assert_eq!(v["total"], 2);
1375        assert_eq!(v["truncated"], true);
1376    }
1377
1378    #[tokio::test]
1379    async fn top_tool_rejects_an_unknown_ranking() {
1380        let s = seeded_server().await;
1381        let result = s
1382            .dispatch(
1383                "top",
1384                view_args_with("r", "main", serde_json::json!({ "by": "sideways" })),
1385            )
1386            .await
1387            .unwrap();
1388        assert_eq!(result.is_error, Some(true));
1389        assert!(result_text(&result).contains("sideways"));
1390    }
1391
1392    #[tokio::test]
1393    async fn top_tool_requires_the_ranking_argument() {
1394        let s = seeded_server().await;
1395        let result = s
1396            .dispatch("top", view_args_with("r", "main", serde_json::json!({})))
1397            .await
1398            .unwrap();
1399        assert_eq!(result.is_error, Some(true));
1400        assert!(result_text(&result).contains("by"));
1401    }
1402
1403    #[tokio::test]
1404    async fn list_tool_filters_by_kind() {
1405        let v = call("list", serde_json::json!({ "kind": "function" })).await;
1406        assert_eq!(v["total"], 2);
1407        assert!(
1408            v["items"]
1409                .as_array()
1410                .unwrap()
1411                .iter()
1412                .all(|l| l["item"]["kind"] == "function")
1413        );
1414    }
1415
1416    #[tokio::test]
1417    async fn list_tool_filters_by_path_prefix() {
1418        let v = call("list", serde_json::json!({ "path_prefix": "lib" })).await;
1419        assert_eq!(v["total"], 1);
1420        assert_eq!(v["items"][0]["path"], "lib.rs");
1421    }
1422
1423    #[tokio::test]
1424    async fn list_tool_reports_truncation() {
1425        let v = call("list", serde_json::json!({ "limit": 1 })).await;
1426        assert_eq!(v["items"].as_array().unwrap().len(), 1);
1427        assert_eq!(v["truncated"], true);
1428        assert_eq!(v["total"], 2);
1429    }
1430
1431    #[tokio::test]
1432    async fn list_tool_rejects_an_unknown_kind() {
1433        let s = seeded_server().await;
1434        let result = s
1435            .dispatch(
1436                "list",
1437                view_args_with("r", "main", serde_json::json!({ "kind": "gizmo" })),
1438            )
1439            .await
1440            .unwrap();
1441        assert_eq!(result.is_error, Some(true));
1442        assert!(result_text(&result).contains("gizmo"));
1443    }
1444
1445    // ---- unknown view vs empty result (#210) -------------------------------
1446
1447    /// Every tool that takes a view selector, with valid arguments except the
1448    /// view id.
1449    const VIEW_SCOPED: &[&str] = &[
1450        "search",
1451        "node",
1452        "callers",
1453        "callees",
1454        "impact",
1455        "explore",
1456        "overview",
1457        "top",
1458        "list",
1459        "unreferenced",
1460    ];
1461
1462    fn args_for(tool: &str, repo: &str, view: &str) -> Option<Map<String, Value>> {
1463        let mut m = serde_json::json!({ "repo": repo, "view_id": view })
1464            .as_object()
1465            .unwrap()
1466            .clone();
1467        // Per-tool required extras.
1468        if matches!(
1469            tool,
1470            "search" | "node" | "callers" | "callees" | "impact" | "explore"
1471        ) {
1472            m.insert("name".into(), Value::String("helper".into()));
1473        }
1474        if tool == "top" {
1475            m.insert("by".into(), Value::String("fan_in".into()));
1476        }
1477        Some(m)
1478    }
1479
1480    #[tokio::test]
1481    async fn an_unknown_view_id_is_an_error_not_an_empty_result() {
1482        let s = seeded_server().await;
1483        for tool in VIEW_SCOPED {
1484            let result = s.dispatch(tool, args_for(tool, "r", "mian")).await.unwrap();
1485            assert_eq!(result.is_error, Some(true), "{tool} must reject 'mian'");
1486            let text = result_text(&result);
1487            assert!(
1488                text.contains("r/mian"),
1489                "{tool} must name the selector: {text}"
1490            );
1491        }
1492    }
1493
1494    #[tokio::test]
1495    async fn an_unknown_repo_is_an_error_not_an_empty_result() {
1496        let s = seeded_server().await;
1497        for tool in VIEW_SCOPED {
1498            let result = s
1499                .dispatch(tool, args_for(tool, "does-not-exist", "main"))
1500                .await
1501                .unwrap();
1502            assert_eq!(result.is_error, Some(true), "{tool} must reject the repo");
1503        }
1504    }
1505
1506    #[tokio::test]
1507    async fn an_unknown_view_error_lists_the_views_that_exist() {
1508        let s = seeded_server().await;
1509        let result = s
1510            .dispatch("search", args_for("search", "r", "mian"))
1511            .await
1512            .unwrap();
1513        let text = result_text(&result);
1514        assert!(
1515            text.contains("r/main"),
1516            "must point at the real view: {text}"
1517        );
1518        assert!(
1519            text.contains("views"),
1520            "must name the discovery tool: {text}"
1521        );
1522    }
1523
1524    #[tokio::test]
1525    async fn an_absent_symbol_in_a_valid_view_is_still_an_empty_result() {
1526        // The other half of #210: the two cases must be distinguishable, so a
1527        // genuine miss must NOT become an error.
1528        let s = seeded_server().await;
1529        let result = s
1530            .dispatch("search", args_for("search", "r", "main"))
1531            .await
1532            .unwrap();
1533        assert_ne!(result.is_error, Some(true));
1534
1535        let result = s
1536            .dispatch(
1537                "callers",
1538                Some(
1539                    serde_json::json!({ "repo": "r", "view_id": "main", "name": "nonexistent" })
1540                        .as_object()
1541                        .unwrap()
1542                        .clone(),
1543                ),
1544            )
1545            .await
1546            .unwrap();
1547        assert_ne!(result.is_error, Some(true), "a real miss is not an error");
1548        let v: Value = serde_json::from_str(&result_text(&result)).unwrap();
1549        assert_eq!(v["callers"], serde_json::json!([]));
1550        // And the answer says the name is defined nowhere, which is what makes
1551        // the empty list readable (#249).
1552        assert_eq!(v["definition_count"], 0);
1553    }
1554
1555    #[tokio::test]
1556    async fn diff_rejects_an_unknown_view_on_either_side() {
1557        let s = seeded_server().await;
1558        for (a, b) in [("main", "nope"), ("nope", "main")] {
1559            let result = s
1560                .dispatch(
1561                    "diff",
1562                    Some(
1563                        serde_json::json!({ "repo": "r", "view_a": a, "view_b": b })
1564                            .as_object()
1565                            .unwrap()
1566                            .clone(),
1567                    ),
1568                )
1569                .await
1570                .unwrap();
1571            assert_eq!(result.is_error, Some(true), "diff {a}->{b}");
1572            assert!(result_text(&result).contains("r/nope"));
1573        }
1574    }
1575
1576    #[tokio::test]
1577    async fn views_tool_lists_indexed_views() {
1578        let s = seeded_server().await;
1579        let result = s.dispatch("views", None).await.unwrap();
1580        assert_ne!(result.is_error, Some(true));
1581        let v: Value = serde_json::from_str(&result_text(&result)).unwrap();
1582        assert_eq!(v.as_array().unwrap().len(), 1);
1583        assert_eq!(v[0]["repo"], "r");
1584        assert_eq!(v[0]["view_id"], "main");
1585        assert_eq!(v[0]["files"], 2, "lib.rs + main.rs");
1586    }
1587
1588    #[tokio::test]
1589    async fn views_tool_is_empty_on_a_fresh_store() {
1590        let s = server("test-root");
1591        let result = s.dispatch("views", None).await.unwrap();
1592        let v: Value = serde_json::from_str(&result_text(&result)).unwrap();
1593        assert!(v.as_array().unwrap().is_empty());
1594    }
1595
1596    #[tokio::test]
1597    async fn status_reports_the_number_of_indexed_views() {
1598        let s = seeded_server().await;
1599        let v: Value =
1600            serde_json::from_str(&result_text(&s.dispatch("status", None).await.unwrap())).unwrap();
1601        assert_eq!(v["status"], "ok");
1602        assert_eq!(v["views"], 1, "an empty store must be distinguishable");
1603
1604        let empty = server("test-root");
1605        let v: Value =
1606            serde_json::from_str(&result_text(&empty.dispatch("status", None).await.unwrap()))
1607                .unwrap();
1608        assert_eq!(v["views"], 0);
1609    }
1610
1611    #[tokio::test]
1612    async fn aggregate_tools_require_a_view_selector() {
1613        let s = seeded_server().await;
1614        for tool in ["overview", "top", "list", "unreferenced"] {
1615            let result = s.dispatch(tool, None).await.unwrap();
1616            assert_eq!(result.is_error, Some(true), "{tool} should reject no args");
1617            assert!(result_text(&result).contains("repo"));
1618        }
1619    }
1620
1621    /// A view with a used function, an unused one, and a `mod tests` block.
1622    const DEAD_SRC: &str = "fn helper() {}\n\
1623                            fn main() { helper(); }\n\
1624                            fn orphan() {}\n\
1625                            #[cfg(test)]\n\
1626                            mod tests {\n    \
1627                                fn t_only() {}\n\
1628                            }\n";
1629
1630    async fn call_on(server: &GonzaloMcp, tool: &str, extra: Value) -> Value {
1631        let result = server
1632            .dispatch(tool, view_args_with("r", "main", extra))
1633            .await
1634            .unwrap();
1635        assert_ne!(result.is_error, Some(true), "tool {tool} errored");
1636        serde_json::from_str(&result_text(&result)).unwrap()
1637    }
1638
1639    fn item_names(v: &Value) -> Vec<String> {
1640        v["items"]
1641            .as_array()
1642            .unwrap()
1643            .iter()
1644            .map(|l| l["item"]["name"].as_str().unwrap().to_string())
1645            .collect()
1646    }
1647
1648    #[tokio::test]
1649    async fn unreferenced_tool_reports_only_uncalled_symbols() {
1650        let v = call("unreferenced", serde_json::json!({})).await;
1651        // The seeded view is `helper` (called from main) + `main` (called by
1652        // nothing), so only `main` is a candidate.
1653        assert_eq!(item_names(&v), vec!["main"]);
1654        assert_eq!(v["items"][0]["path"], "main.rs");
1655    }
1656
1657    #[tokio::test]
1658    async fn unreferenced_tool_excludes_test_scopes_by_default() {
1659        let s = seeded_with(&[("lib.rs", DEAD_SRC)]).await;
1660        let v = call_on(&s, "unreferenced", serde_json::json!({})).await;
1661        let names = item_names(&v);
1662        assert!(names.contains(&"orphan".to_string()));
1663        assert!(!names.contains(&"t_only".to_string()));
1664    }
1665
1666    #[tokio::test]
1667    async fn unreferenced_tool_can_include_test_scopes() {
1668        let s = seeded_with(&[("lib.rs", DEAD_SRC)]).await;
1669        let v = call_on(
1670            &s,
1671            "unreferenced",
1672            serde_json::json!({ "exclude_tests": false }),
1673        )
1674        .await;
1675        assert!(item_names(&v).contains(&"t_only".to_string()));
1676    }
1677
1678    #[tokio::test]
1679    async fn unreferenced_tool_applies_the_symbol_filter() {
1680        let s = seeded_with(&[("lib.rs", DEAD_SRC)]).await;
1681        let v = call_on(&s, "unreferenced", serde_json::json!({ "kind": "struct" })).await;
1682        assert_eq!(v["total"], 0);
1683    }
1684
1685    #[tokio::test]
1686    async fn unreferenced_tool_reports_truncation() {
1687        let s = seeded_with(&[("lib.rs", "fn a() {} fn b() {} fn c() {}")]).await;
1688        let v = call_on(&s, "unreferenced", serde_json::json!({ "limit": 2 })).await;
1689        assert_eq!(v["items"].as_array().unwrap().len(), 2);
1690        assert_eq!(v["truncated"], true);
1691        assert_eq!(v["total"], 3);
1692    }
1693
1694    #[tokio::test]
1695    async fn unreferenced_tool_rejects_a_non_boolean_exclude_tests() {
1696        let s = seeded_server().await;
1697        let result = s
1698            .dispatch(
1699                "unreferenced",
1700                view_args_with("r", "main", serde_json::json!({ "exclude_tests": "yes" })),
1701            )
1702            .await
1703            .unwrap();
1704        assert_eq!(result.is_error, Some(true));
1705        assert!(result_text(&result).contains("exclude_tests"));
1706    }
1707
1708    #[test]
1709    fn unreferenced_tool_description_states_the_heuristic_blind_spot() {
1710        let tool = GonzaloMcp::tools(false)
1711            .into_iter()
1712            .find(|t| t.name == "unreferenced")
1713            .expect("unreferenced is advertised");
1714        let description = tool.description.as_deref().unwrap_or_default();
1715        assert!(
1716            description.contains("heuristic"),
1717            "must not present candidates as proof: {description}"
1718        );
1719        assert!(
1720            description.contains("higher-order"),
1721            "must name the higher-order-usage blind spot: {description}"
1722        );
1723    }
1724
1725    #[tokio::test]
1726    async fn diff_tool_reports_changes_between_two_views() {
1727        // Seed two views of `r` sharing the store, differing by one symbol.
1728        let fs = Arc::new(FsStore::new(tempfile::tempdir().unwrap().keep()));
1729        for (view, src) in [
1730            ("v1", "fn keep() {}\nfn gone() {}"),
1731            ("v2", "fn keep() {}\nfn fresh() {}"),
1732        ] {
1733            let hash = fs
1734                .put_blob(&build_rust(src).to_slice_bytes())
1735                .await
1736                .unwrap();
1737            let mut manifest = Manifest::new();
1738            manifest.insert("lib.rs", hash);
1739            let body = manifest.to_body();
1740            let record = Record {
1741                revision: Revision::initial(body.bytes()),
1742                parent: None,
1743                body,
1744                kind: RecordKind::GraphManifest,
1745                meta: Meta {
1746                    author: Identity::new("t"),
1747                    origin_system: "t".into(),
1748                    created: 0,
1749                    updated: 0,
1750                    labels: BTreeMap::new(),
1751                },
1752                links: Vec::new(),
1753                key: Manifest::key("r", view),
1754                ancestors: Vec::new(),
1755                deleted_at: None,
1756                deleted_blob: None,
1757                deleted_kind: None,
1758            };
1759            assert!(matches!(
1760                fs.put(record, None).await.unwrap(),
1761                PutResult::Committed(_)
1762            ));
1763        }
1764        let mcp = GonzaloMcp::new(Service::new(fs.clone(), fs), "test-root");
1765
1766        let diff_args = Some(
1767            serde_json::json!({ "repo": "r", "view_a": "v1", "view_b": "v2" })
1768                .as_object()
1769                .unwrap()
1770                .clone(),
1771        );
1772        let result = mcp.dispatch("diff", diff_args).await.unwrap();
1773        let diff: Value = serde_json::from_str(&result_text(&result)).unwrap();
1774        let added: Vec<&str> = diff["added_symbols"]
1775            .as_array()
1776            .unwrap()
1777            .iter()
1778            .map(|s| s["item"]["name"].as_str().unwrap())
1779            .collect();
1780        assert!(added.contains(&"fresh"));
1781        let removed: Vec<&str> = diff["removed_symbols"]
1782            .as_array()
1783            .unwrap()
1784            .iter()
1785            .map(|s| s["item"]["name"].as_str().unwrap())
1786            .collect();
1787        assert!(removed.contains(&"gone"));
1788    }
1789
1790    #[tokio::test]
1791    async fn status_reports_ok_and_configured_root() {
1792        let s = server("/tmp/some-root");
1793        let payload = s.status_json().await;
1794        assert_eq!(payload["status"], "ok");
1795        assert_eq!(payload["root"], "/tmp/some-root");
1796    }
1797
1798    #[tokio::test]
1799    async fn search_returns_located_definitions() {
1800        let s = seeded_server().await;
1801        let result = s
1802            .dispatch("search", args("r", "main", "helper"))
1803            .await
1804            .unwrap();
1805        assert_ne!(result.is_error, Some(true));
1806        let defs: Value = serde_json::from_str(&result_text(&result)).unwrap();
1807        assert_eq!(defs[0]["path"], "lib.rs");
1808        assert_eq!(defs[0]["item"]["name"], "helper");
1809    }
1810
1811    #[tokio::test]
1812    async fn callees_returns_names() {
1813        let s = seeded_server().await;
1814        let callees = s
1815            .dispatch("callees", args("r", "main", "main"))
1816            .await
1817            .unwrap();
1818        let v: Value = serde_json::from_str(&result_text(&callees)).unwrap();
1819        assert_eq!(v["callees"], serde_json::json!(["helper"]));
1820        assert_eq!(v["definition_count"], 1, "`main` is defined once");
1821    }
1822
1823    // ---- resolution-gated impact (#207) ------------------------------------
1824
1825    #[tokio::test]
1826    async fn impact_returns_a_report_with_paths() {
1827        let s = seeded_server().await;
1828        let v: Value = serde_json::from_str(&result_text(
1829            &s.dispatch("impact", args("r", "main", "helper"))
1830                .await
1831                .unwrap(),
1832        ))
1833        .unwrap();
1834        // Not a bare name list any more: every node carries its defining path,
1835        // and the honesty fields ride alongside.
1836        assert_eq!(v["reached"][0]["name"], "main");
1837        assert_eq!(v["reached"][0]["path"], "main.rs");
1838        assert_eq!(v["ambiguous_edges"], 0);
1839        assert_eq!(v["truncated"], false);
1840    }
1841
1842    #[tokio::test]
1843    async fn impact_does_not_merge_subgraphs_through_a_shared_name() {
1844        // `helper` is defined in both files; nothing else is shared. The old
1845        // name-matched closure reached `top_b` from `leaf_a` (#207).
1846        let s = seeded_with(&[
1847            (
1848                "a.rs",
1849                "fn leaf_a() {}\nfn helper() { leaf_a(); }\nfn top_a() { helper(); }",
1850            ),
1851            (
1852                "b.rs",
1853                "fn leaf_b() {}\nfn helper() { leaf_b(); }\nfn top_b() { helper(); }",
1854            ),
1855        ])
1856        .await;
1857        let v: Value = serde_json::from_str(&result_text(
1858            &s.dispatch("impact", args("r", "main", "leaf_a"))
1859                .await
1860                .unwrap(),
1861        ))
1862        .unwrap();
1863        let names: Vec<&str> = v["reached"]
1864            .as_array()
1865            .unwrap()
1866            .iter()
1867            .map(|n| n["name"].as_str().unwrap())
1868            .collect();
1869        assert!(names.contains(&"top_a"), "{names:?}");
1870        assert!(!names.contains(&"top_b"), "must not cross files: {names:?}");
1871    }
1872
1873    #[tokio::test]
1874    async fn impact_accepts_a_max_depth() {
1875        let s = seeded_with(&[("a.rs", "fn l() {}\nfn m() { l(); }\nfn t() { m(); }")]).await;
1876        let mut a = args("r", "main", "l").unwrap();
1877        a.insert("max_depth".into(), serde_json::json!(1));
1878        let v: Value =
1879            serde_json::from_str(&result_text(&s.dispatch("impact", Some(a)).await.unwrap()))
1880                .unwrap();
1881        assert_eq!(v["reached"].as_array().unwrap().len(), 1);
1882        assert_eq!(v["reached"][0]["name"], "m");
1883        assert_eq!(v["truncated"], true);
1884    }
1885
1886    #[tokio::test]
1887    async fn impact_rejects_a_non_integer_max_depth() {
1888        let s = seeded_server().await;
1889        let mut a = args("r", "main", "helper").unwrap();
1890        a.insert("max_depth".into(), serde_json::json!("deep"));
1891        let result = s.dispatch("impact", Some(a)).await.unwrap();
1892        assert_eq!(result.is_error, Some(true));
1893        assert!(result_text(&result).contains("max_depth"));
1894    }
1895
1896    #[tokio::test]
1897    async fn node_aggregates_definitions_callers_and_callees() {
1898        let s = seeded_server().await;
1899        let result = s
1900            .dispatch("node", args("r", "main", "helper"))
1901            .await
1902            .unwrap();
1903        let node: Value = serde_json::from_str(&result_text(&result)).unwrap();
1904        assert_eq!(node["definitions"][0]["path"], "lib.rs");
1905        assert_eq!(node["callers"], serde_json::json!(["main"]));
1906        assert_eq!(node["callees"], serde_json::json!([]));
1907    }
1908
1909    #[tokio::test]
1910    async fn missing_arguments_are_a_tool_error() {
1911        let s = seeded_server().await;
1912        // No `name` provided.
1913        let bad = Some(
1914            serde_json::json!({ "repo": "r", "view_id": "main" })
1915                .as_object()
1916                .unwrap()
1917                .clone(),
1918        );
1919        let result = s.dispatch("search", bad).await.unwrap();
1920        assert_eq!(result.is_error, Some(true));
1921        assert!(result_text(&result).contains("name"));
1922    }
1923
1924    #[tokio::test]
1925    async fn dispatch_unknown_tool_is_method_not_found() {
1926        let s = server("/r");
1927        assert!(s.dispatch("no_such_tool", None).await.is_err());
1928    }
1929
1930    // ---- ambiguity signal on callers/callees (#249) -----------------------
1931
1932    /// Two files define `foo`; a third calls it. Asking for callers of `foo`
1933    /// merges callers of both, with nothing in the answer saying so.
1934    async fn overloaded() -> GonzaloMcp {
1935        seeded_with(&[
1936            ("a.rs", "fn foo() {}"),
1937            ("b.rs", "fn foo() {}"),
1938            ("c.rs", "fn caller() { foo(); }"),
1939        ])
1940        .await
1941    }
1942
1943    #[tokio::test]
1944    async fn callers_reports_how_many_definitions_the_name_has() {
1945        let s = overloaded().await;
1946        let v = call_on(&s, "callers", serde_json::json!({ "name": "foo" })).await;
1947        assert_eq!(v["callers"], serde_json::json!(["caller"]));
1948        assert_eq!(
1949            v["definition_count"], 2,
1950            "the result merges two symbols: {v}"
1951        );
1952        assert_eq!(v["defined_in"], serde_json::json!(["a.rs", "b.rs"]));
1953    }
1954
1955    #[tokio::test]
1956    async fn callees_reports_how_many_definitions_the_name_has() {
1957        let s = overloaded().await;
1958        let v = call_on(&s, "callees", serde_json::json!({ "name": "foo" })).await;
1959        assert_eq!(v["definition_count"], 2, "{v}");
1960    }
1961
1962    #[tokio::test]
1963    async fn an_unambiguous_name_reports_one_definition() {
1964        // Not zero, and not absent: the field always answers.
1965        let v = call("callers", serde_json::json!({ "name": "helper" })).await;
1966        assert_eq!(v["callers"], serde_json::json!(["main"]));
1967        assert_eq!(v["definition_count"], 1, "{v}");
1968    }
1969
1970    #[tokio::test]
1971    async fn a_name_defined_nowhere_reports_zero_definitions() {
1972        let v = call("callers", serde_json::json!({ "name": "absent" })).await;
1973        assert_eq!(v["callers"], serde_json::json!([]));
1974        assert_eq!(v["definition_count"], 0, "{v}");
1975    }
1976
1977    #[tokio::test]
1978    async fn the_defining_paths_are_dropped_once_the_name_is_hopeless() {
1979        // Past a handful the list stops being a lead and becomes noise; the
1980        // count alone already says "do not read this as precise".
1981        let slices: Vec<(String, String)> = (0..MAX_DEFINED_IN + 2)
1982            .map(|i| (format!("f{i}.rs"), "fn foo() {}".to_string()))
1983            .collect();
1984        let borrowed: Vec<(&str, &str)> = slices
1985            .iter()
1986            .map(|(p, src)| (p.as_str(), src.as_str()))
1987            .collect();
1988        let s = seeded_with(&borrowed).await;
1989
1990        let v = call_on(&s, "callers", serde_json::json!({ "name": "foo" })).await;
1991        assert_eq!(v["definition_count"], MAX_DEFINED_IN + 2, "{v}");
1992        assert!(v["defined_in"].is_null(), "paths dropped: {v}");
1993    }
1994
1995    // ---- module-level callers (#268) ---------------------------------------
1996
1997    #[tokio::test]
1998    async fn callers_separates_functions_from_module_level_declarations() {
1999        // `callers` must keep meaning what it meant — functions that call this.
2000        // The module-level ones are real dependencies but a different question,
2001        // so they travel in their own field (#268).
2002        let s = seeded_with_language(
2003            gonzalo_graph::Language::TypeScript,
2004            &[(
2005                "app.ts",
2006                "const schema = z.object({});\nconst run = () => { z.object({}); };",
2007            )],
2008        )
2009        .await;
2010        let v = call_on(&s, "callers", serde_json::json!({ "name": "object" })).await;
2011        assert_eq!(v["callers"], serde_json::json!(["run"]));
2012        assert_eq!(v["module_callers"], serde_json::json!(["schema"]));
2013    }
2014
2015    #[tokio::test]
2016    async fn callees_carries_no_module_field() {
2017        // The split is meaningful for callers only: `callees` asks what one
2018        // named thing calls, and its scope is whatever that thing is.
2019        let v = call("callees", serde_json::json!({ "name": "main" })).await;
2020        assert_eq!(v["callees"], serde_json::json!(["helper"]));
2021        assert!(v["module_callers"].is_null(), "{v}");
2022    }
2023
2024    // ---- record tools (#197) ----------------------------------------------
2025
2026    /// A server over an empty store, with the store handle so a test can seed
2027    /// records directly and assert what the tools did to them.
2028    fn record_server(writes: bool) -> (GonzaloMcp, Arc<FsStore>) {
2029        let fs = Arc::new(FsStore::new(tempfile::tempdir().unwrap().keep()));
2030        let server =
2031            GonzaloMcp::new(Service::new(fs.clone(), fs.clone()), "test-root").with_writes(writes);
2032        (server, fs)
2033    }
2034
2035    fn topic(key: RecordKey, payload: &[u8]) -> Record {
2036        let body = Body::Inline(payload.to_vec());
2037        Record {
2038            revision: Revision::initial(body.bytes()),
2039            parent: None,
2040            body,
2041            kind: RecordKind::Topic,
2042            meta: Meta {
2043                author: Identity::new("tester"),
2044                origin_system: "test".into(),
2045                created: 0,
2046                updated: 0,
2047                labels: BTreeMap::new(),
2048            },
2049            links: Vec::new(),
2050            key,
2051            ancestors: Vec::new(),
2052            deleted_at: None,
2053            deleted_blob: None,
2054            deleted_kind: None,
2055        }
2056    }
2057
2058    /// Seed a record, asserting the write committed rather than conflicted.
2059    async fn put_ok(fs: &FsStore, record: Record) -> Revision {
2060        match fs.put(record, None).await.unwrap() {
2061            PutResult::Committed(rev) => rev,
2062            PutResult::Conflict(c) => panic!("unexpected conflict: {c:?}"),
2063        }
2064    }
2065
2066    fn record_args(extra: Value) -> Option<Map<String, Value>> {
2067        Some(extra.as_object().expect("object").clone())
2068    }
2069
2070    #[test]
2071    fn write_tools_are_advertised_only_when_writes_are_enabled() {
2072        // An agent must never see a tool it cannot call, so the advertised list
2073        // is the mode: read-only by default, writes on request (#197).
2074        let read_only: Vec<String> = GonzaloMcp::tools(false)
2075            .iter()
2076            .map(|t| t.name.to_string())
2077            .collect();
2078        assert!(read_only.contains(&"record_get".to_string()));
2079        assert!(read_only.contains(&"record_list".to_string()));
2080        assert!(!read_only.contains(&"record_put".to_string()));
2081        assert!(!read_only.contains(&"record_delete".to_string()));
2082
2083        let writable: Vec<String> = GonzaloMcp::tools(true)
2084            .iter()
2085            .map(|t| t.name.to_string())
2086            .collect();
2087        for tool in ["record_get", "record_list", "record_put", "record_delete"] {
2088            assert!(writable.contains(&tool.to_string()), "missing {tool}");
2089        }
2090    }
2091
2092    #[tokio::test]
2093    async fn record_get_returns_the_stored_record() {
2094        let (server, fs) = record_server(false);
2095        let key = RecordKey::new("memory", "topics", "rust");
2096        put_ok(&fs, topic(key.clone(), b"{\"note\":1}")).await;
2097
2098        let result = server
2099            .dispatch(
2100                "record_get",
2101                record_args(
2102                    serde_json::json!({"namespace": "memory", "collection": "topics", "id": "rust"}),
2103                ),
2104            )
2105            .await
2106            .unwrap();
2107        assert_ne!(result.is_error, Some(true));
2108        let v: Value = serde_json::from_str(&result_text(&result)).unwrap();
2109        assert_eq!(v["key"]["id"], "rust");
2110        assert_eq!(v["kind"], "Topic");
2111    }
2112
2113    #[tokio::test]
2114    async fn record_get_of_an_absent_key_is_an_error_not_an_empty_result() {
2115        // Same rule the view selector follows (#210): an agent reads an empty
2116        // answer as "there is nothing there", which is not what a typo means.
2117        let (server, _fs) = record_server(false);
2118        let result = server
2119            .dispatch(
2120                "record_get",
2121                record_args(
2122                    serde_json::json!({"namespace": "memory", "collection": "topics", "id": "nope"}),
2123                ),
2124            )
2125            .await
2126            .unwrap();
2127        assert_eq!(result.is_error, Some(true));
2128        assert!(result_text(&result).contains("memory/topics/nope"));
2129    }
2130
2131    #[tokio::test]
2132    async fn record_list_lists_a_collection_and_hides_tombstones() {
2133        let (server, fs) = record_server(false);
2134        let kept = RecordKey::new("memory", "topics", "kept");
2135        let gone = RecordKey::new("memory", "topics", "gone");
2136        put_ok(&fs, topic(kept.clone(), b"a")).await;
2137        let rev = put_ok(&fs, topic(gone.clone(), b"b")).await;
2138        assert_eq!(
2139            fs.delete(&gone, Some(rev)).await.unwrap(),
2140            DeleteResult::Deleted
2141        );
2142
2143        let result = server
2144            .dispatch(
2145                "record_list",
2146                record_args(serde_json::json!({"namespace": "memory", "collection": "topics"})),
2147            )
2148            .await
2149            .unwrap();
2150        let v: Value = serde_json::from_str(&result_text(&result)).unwrap();
2151        assert_eq!(v["keys"], serde_json::json!(["memory/topics/kept"]));
2152    }
2153
2154    #[tokio::test]
2155    async fn write_tools_are_unavailable_in_read_only_mode() {
2156        let (server, _fs) = record_server(false);
2157        for tool in ["record_put", "record_delete"] {
2158            let err = server
2159                .dispatch(
2160                    tool,
2161                    record_args(serde_json::json!({
2162                        "namespace": "memory", "collection": "topics", "id": "x",
2163                        "kind": "Topic", "body": "{}"
2164                    })),
2165                )
2166                .await;
2167            assert!(err.is_err(), "{tool} must not be callable read-only");
2168        }
2169    }
2170
2171    #[tokio::test]
2172    async fn record_put_creates_then_conflicts_without_the_current_revision() {
2173        let (server, fs) = record_server(true);
2174        let result = server
2175            .dispatch(
2176                "record_put",
2177                record_args(serde_json::json!({
2178                    "namespace": "memory", "collection": "topics", "id": "new",
2179                    "kind": "Topic", "body": "hello"
2180                })),
2181            )
2182            .await
2183            .unwrap();
2184        assert_ne!(result.is_error, Some(true), "{}", result_text(&result));
2185        let v: Value = serde_json::from_str(&result_text(&result)).unwrap();
2186        assert_eq!(v["committed"], true);
2187
2188        let key = RecordKey::new("memory", "topics", "new");
2189        let stored = fs.get(&key).await.unwrap().expect("stored");
2190        assert_eq!(stored.body.bytes(), b"hello");
2191
2192        // A second create over a live record is a conflict, reported as data
2193        // rather than an error: it is a normal OCC outcome the agent can act on.
2194        let again = server
2195            .dispatch(
2196                "record_put",
2197                record_args(serde_json::json!({
2198                    "namespace": "memory", "collection": "topics", "id": "new",
2199                    "kind": "Topic", "body": "clobber"
2200                })),
2201            )
2202            .await
2203            .unwrap();
2204        let v: Value = serde_json::from_str(&result_text(&again)).unwrap();
2205        assert_eq!(v["committed"], false);
2206        assert_eq!(fs.get(&key).await.unwrap().unwrap().body.bytes(), b"hello");
2207    }
2208
2209    #[tokio::test]
2210    async fn record_delete_removes_the_record() {
2211        let (server, fs) = record_server(true);
2212        let key = RecordKey::new("memory", "topics", "bye");
2213        put_ok(&fs, topic(key.clone(), b"a")).await;
2214
2215        let result = server
2216            .dispatch(
2217                "record_delete",
2218                record_args(
2219                    serde_json::json!({"namespace": "memory", "collection": "topics", "id": "bye"}),
2220                ),
2221            )
2222            .await
2223            .unwrap();
2224        assert_ne!(result.is_error, Some(true), "{}", result_text(&result));
2225        assert_eq!(fs.get(&key).await.unwrap(), None);
2226        // The tombstone is still there for replication.
2227        assert!(fs.get_raw(&key).await.unwrap().unwrap().is_tombstone());
2228    }
2229}