1use 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
43const MAX_DEFINED_IN: usize = 10;
46
47#[derive(Clone)]
49pub struct GonzaloMcp {
50 service: Service,
51 root: String,
52 writes: bool,
57}
58
59impl GonzaloMcp {
60 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 pub fn with_writes(mut self, writes: bool) -> Self {
73 self.writes = writes;
74 self
75 }
76
77 pub fn writes_enabled(&self) -> bool {
79 self.writes
80 }
81
82 pub fn service(&self) -> &Service {
84 &self.service
85 }
86
87 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 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 pub async fn dispatch(
252 &self,
253 name: &str,
254 arguments: Option<Map<String, Value>>,
255 ) -> Result<CallToolResult, rmcp::ErrorData> {
256 if name == "status" {
258 return Ok(CallToolResult::success(vec![Content::text(
259 self.status_json().await.to_string(),
260 )]));
261 }
262
263 if name == "views" {
265 return self.result(self.service.graph_views().await);
266 }
267
268 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 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 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 if !matches!(
308 name,
309 "search" | "node" | "callers" | "callees" | "impact" | "explore"
310 ) {
311 return Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>());
312 }
313
314 let (repo, view, sym) = match view_args(&arguments) {
316 Ok(t) => t,
317 Err(msg) => return Ok(tool_error(msg)),
318 };
319
320 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 _ => Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>()),
346 }
347 }
348
349 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 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 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 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 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 _ => Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>()),
465 }
466 }
467
468 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 Err(e) => Some(tool_error(e.to_string())),
494 }
495 }
496
497 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 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 _ => Err(rmcp::ErrorData::method_not_found::<CallToolRequestMethod>()),
569 }
570 }
571
572 async fn call_graph(
582 &self,
583 repo: &str,
584 view: &str,
585 sym: &str,
586 tool: &str,
587 ) -> Result<CallToolResult, rmcp::ErrorData> {
588 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 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
666fn 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
690fn 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
711const DEFAULT_TOP_LIMIT: usize = 20;
713const DEFAULT_LIST_LIMIT: usize = 100;
715
716fn 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
745fn 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
778fn 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
822fn 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
873fn 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
902fn 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
908fn 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
923fn 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
941fn 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
974fn 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
983fn 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
1000fn 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
1016fn 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
1026fn 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
1035fn selector_args(arguments: &Option<Map<String, Value>>) -> Result<(String, String), String> {
1038 Ok((str_arg(arguments, "repo")?, str_arg(arguments, "view_id")?))
1039}
1040
1041fn 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
1056fn 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
1069fn 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
1082fn 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
1097fn 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
1109fn 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
1129fn 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
1138fn tool_error(msg: impl Into<String>) -> CallToolResult {
1140 CallToolResult::error(vec![Content::text(msg.into())])
1141}
1142
1143fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 #[tokio::test]
1998 async fn callers_separates_functions_from_module_level_declarations() {
1999 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 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 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 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 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 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 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 assert!(fs.get_raw(&key).await.unwrap().unwrap().is_tombstone());
2228 }
2229}