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