From 636e51f8254bc469e0efa2178b17ff141c875178 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 5 Mar 2026 17:31:45 +0000 Subject: [PATCH 1/3] Add pagination support and unit tests to rubydex MCP tools Add offset/total pagination to search_declarations, get_descendants, and find_constant_references. Uses a two-pass paginate! macro: filter pass counts total, map pass builds JSON only for the requested page. - search_declarations: response wrapped in {results, total} envelope - get_descendants: gains limit (default 100, max 500) and offset - find_constant_references: gains offset; total always present - limit=0 treated as default to guard against accidental empty pages - Tool descriptions and server instructions mention pagination - Add GraphTest::into_graph() for test graph injection - Add 26 unit tests calling tool handlers directly (no process spawn) - Windows-compatible test URIs via test_root()/test_uri() helpers --- rust/rubydex-mcp/src/server.rs | 743 +++++++++++++++++++--- rust/rubydex-mcp/src/tools.rs | 8 + rust/rubydex-mcp/tests/mcp.rs | 11 +- rust/rubydex/src/test_utils/graph_test.rs | 5 + 4 files changed, 690 insertions(+), 77 deletions(-) diff --git a/rust/rubydex-mcp/src/server.rs b/rust/rubydex-mcp/src/server.rs index 5a0194c8..411b2726 100644 --- a/rust/rubydex-mcp/src/server.rs +++ b/rust/rubydex-mcp/src/server.rs @@ -203,57 +203,80 @@ fn format_ancestors(graph: &Graph, ancestors: &Ancestors) -> Vec {{ + let filtered: Vec<_> = $items.filter($filter).collect(); + let total = filtered.len(); + let results: Vec = filtered + .into_iter() + .skip($offset) + .take($limit) + .filter_map($map) + .collect(); + (results, total) + }}; +} + #[tool_router] impl RubydexServer { #[tool( - description = "Search for Ruby classes, modules, methods, or constants by name. Use this INSTEAD OF Grep when you know part of a Ruby identifier name and want to find its definition. Returns fully qualified names, kinds, and file locations. Use the `kind` filter (\"Class\", \"Module\", \"Method\", \"Constant\") to narrow results." + description = "Search for Ruby classes, modules, methods, or constants by name. Use this INSTEAD OF Grep when you know part of a Ruby identifier name and want to find its definition. Returns fully qualified names, kinds, and file locations. Use the `kind` filter (\"Class\", \"Module\", \"Method\", \"Constant\") to narrow results. Results are paginated: the response includes `total` (the full count of matches). If `total` exceeds the number of returned results, use `offset` to fetch subsequent pages." )] fn search_declarations(&self, Parameters(params): Parameters) -> String { let state = ensure_graph_ready!(self); let graph = state.graph.as_ref().unwrap(); let ids = rubydex::query::declaration_search(graph, ¶ms.query); - let limit = params.limit.unwrap_or(25).min(100); + let limit = params.limit.filter(|&l| l > 0).unwrap_or(25).min(100); // default 25, max 100 + let offset = params.offset.unwrap_or(0); let kind_filter = params.kind.as_deref(); - let mut results: Vec = Vec::new(); - for id in ids { - if results.len() >= limit { - break; - } - - let Some(decl) = graph.declarations().get(&id) else { - continue; - }; + let (results, total) = paginate!( + ids.iter(), + offset, + limit, + |id| { + let Some(decl) = graph.declarations().get(id) else { + return false; + }; + if let Some(kind) = kind_filter { + decl.kind().eq_ignore_ascii_case(kind) + } else { + true + } + }, + |id| { + let decl = graph.declarations().get(id)?; + let locations: Vec = decl + .definitions() + .iter() + .filter_map(|def_id| { + let def = graph.definitions().get(def_id)?; + let doc = graph.documents().get(def.uri_id())?; + let loc = def.offset().to_location(doc).to_presentation(); + Some(serde_json::json!({ + "path": format_path(doc.uri(), &self.root_path), + "line": loc.start_line(), + })) + }) + .collect(); - if let Some(kind) = kind_filter - && !decl.kind().eq_ignore_ascii_case(kind) - { - continue; - } + Some(serde_json::json!({ + "name": decl.name(), + "kind": decl.kind(), + "locations": locations, + })) + }, + ); - let locations: Vec = decl - .definitions() - .iter() - .filter_map(|def_id| { - let def = graph.definitions().get(def_id)?; - let doc = graph.documents().get(def.uri_id())?; - let loc = def.offset().to_location(doc).to_presentation(); - Some(serde_json::json!({ - "path": format_path(doc.uri(), &self.root_path), - "line": loc.start_line(), - })) - }) - .collect(); - - results.push(serde_json::json!({ - "name": decl.name(), - "kind": decl.kind(), - "locations": locations, - })); - } + let result = serde_json::json!({ + "results": results, + "total": total, + }); - serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string()) + serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string()) } #[tool( @@ -341,7 +364,7 @@ impl RubydexServer { } #[tool( - description = "Find all classes and modules that inherit from or include a given class/module. Returns known descendants, including transitive relationships, for impact analysis before modifying a base class or module." + description = "Find all classes and modules that inherit from or include a given class/module. Returns known descendants (including itself and transitive relationships) for impact analysis before modifying a base class or module. Results are paginated: the response includes `total`. If `total` exceeds the number of returned results, use `offset` to fetch subsequent pages." )] fn get_descendants(&self, Parameters(params): Parameters) -> String { let state = ensure_graph_ready!(self); @@ -349,66 +372,71 @@ impl RubydexServer { let (_, decl) = lookup_declaration!(graph, ¶ms.name); let namespace = require_namespace!(decl, ¶ms.name, "get_descendants"); - let descendants: Vec = namespace - .descendants() - .iter() - .filter_map(|desc_id| { - let desc_decl = graph.declarations().get(desc_id)?; + let limit = params.limit.filter(|&l| l > 0).unwrap_or(100).min(500); // default 100, max 500 + let offset = params.offset.unwrap_or(0); + + let (descendants, total) = paginate!( + namespace.descendants().iter(), + offset, + limit, + |id| graph.declarations().get(id).is_some(), + |id| { + let desc_decl = graph.declarations().get(id)?; Some(serde_json::json!({ "name": desc_decl.name(), "kind": desc_decl.kind(), })) - }) - .collect(); + }, + ); let result = serde_json::json!({ "name": decl.name(), "descendants": descendants, + "total": total, }); serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string()) } #[tool( - description = "Find all resolved references to a Ruby class, module, or constant across the codebase. Returns file paths, line numbers, and columns for each usage." + description = "Find all resolved references to a Ruby class, module, or constant across the codebase. Returns file paths, line numbers, and columns for each usage. Results are paginated: the response includes `total`. If `total` exceeds the number of returned results, use `offset` to fetch subsequent pages." )] fn find_constant_references(&self, Parameters(params): Parameters) -> String { let state = ensure_graph_ready!(self); let graph = state.graph.as_ref().unwrap(); let (_, decl) = lookup_declaration!(graph, ¶ms.name); - let limit = params.limit.unwrap_or(50).min(200); - let mut references: Vec = Vec::with_capacity(limit.min(decl.references().len())); - let mut truncated = false; - - for ref_id in decl.references() { - if references.len() >= limit { - truncated = true; - break; - } - - let Some(const_ref) = graph.constant_references().get(ref_id) else { - continue; - }; + let limit = params.limit.filter(|&l| l > 0).unwrap_or(50).min(200); // default 50, max 200 + let offset = params.offset.unwrap_or(0); - let Some(doc) = graph.documents().get(&const_ref.uri_id()) else { - continue; - }; - let loc = const_ref.offset().to_location(doc).to_presentation(); - references.push(serde_json::json!({ - "path": format_path(doc.uri(), &self.root_path), - "line": loc.start_line(), - "column": loc.start_col(), - })); - } + let (references, total) = paginate!( + decl.references().iter(), + offset, + limit, + |ref_id| { + graph + .constant_references() + .get(ref_id) + .and_then(|r| graph.documents().get(&r.uri_id())) + .is_some() + }, + |ref_id| { + let const_ref = graph.constant_references().get(ref_id)?; + let doc = graph.documents().get(&const_ref.uri_id())?; + let loc = const_ref.offset().to_location(doc).to_presentation(); + Some(serde_json::json!({ + "path": format_path(doc.uri(), &self.root_path), + "line": loc.start_line(), + "column": loc.start_col(), + })) + }, + ); - let mut result = serde_json::json!({ + let result = serde_json::json!({ "name": params.name, "references": references, + "total": total, }); - if truncated { - result["truncated"] = serde_json::Value::Bool(true); - } serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string()) } @@ -519,6 +547,8 @@ Typical workflow: search_declarations -> get_declaration -> find_constant_refere Fully qualified name format: "Foo::Bar" for classes/modules/constants, "Foo::Bar#method_name" for instance methods. +Pagination: search_declarations, get_descendants, and find_constant_references return paginated results. The response includes a `total` count. When `total` exceeds the number of returned items, use `offset` to fetch the next page. + Use Grep instead for: literal string search, log messages, comments, non-Ruby files, or content search rather than structural queries."#; #[tool_handler] @@ -531,3 +561,570 @@ impl ServerHandler for RubydexServer { } } } + +#[cfg(test)] +mod tests { + use super::*; + use rubydex::test_utils::GraphTest; + use serde_json::Value; + + fn parse(json_str: &str) -> Value { + serde_json::from_str(json_str).unwrap() + } + + /// Assert a JSON array field contains an entry with the given "name". + macro_rules! assert_includes { + ($json:expr, $field:literal, $name:expr) => {{ + let json = &$json; + let entries = json[$field] + .as_array() + .expect(concat!("expected '", $field, "' to be an array")); + assert!( + entries.iter().any(|e| e["name"].as_str() == Some($name)), + "Expected '{}' in '{}', got: {:?}", + $name, + $field, + entries.iter().filter_map(|e| e["name"].as_str()).collect::>() + ); + }}; + } + + /// Extract a JSON field as an array, panicking if not an array. + macro_rules! array { + ($json:expr, $field:literal) => { + $json[$field] + .as_array() + .expect(concat!("expected '", $field, "' to be an array")) + }; + } + + /// Assert a JSON field equals the expected u64 value. + macro_rules! assert_json_int { + ($json:expr, $field:literal, $val:expr) => { + assert_eq!( + $json[$field] + .as_u64() + .expect(concat!("expected '", $field, "' to be a number")), + $val + ); + }; + } + + fn assert_error(json_str: &str, expected_type: &str) { + let res = parse(json_str); + assert_eq!( + res["error"].as_str(), + Some(expected_type), + "Expected error '{expected_type}', got: {res}" + ); + assert!(res["message"].as_str().is_some()); + assert!(res["suggestion"].as_str().is_some()); + } + + /// Returns a platform-appropriate test root path and its file URI prefix. + fn test_root() -> (&'static str, &'static str) { + if cfg!(windows) { + ("C:\\test", "file:///C:/test") + } else { + ("/test", "file:///test") + } + } + + fn test_uri(filename: &str) -> String { + let (_, uri_prefix) = test_root(); + format!("{uri_prefix}/{filename}") + } + + /// Build a server from a single Ruby source. + fn server_with_source(source: &str) -> RubydexServer { + server_with_sources(&[(&test_uri("test.rb"), source)]) + } + + /// Build a server from multiple `(uri, source)` pairs. + fn server_with_sources(sources: &[(&str, &str)]) -> RubydexServer { + let mut gt = GraphTest::new(); + for (uri, source) in sources { + gt.index_uri(uri, source); + } + gt.resolve(); + + let (root, _) = test_root(); + let server = RubydexServer::new(root.to_string()); + { + let mut state = server.state.write().unwrap(); + state.graph = Some(gt.into_graph()); + } + server + } + + macro_rules! search_declarations { + ($server:expr, $($field:ident: $val:expr),* $(,)?) => { + parse(&$server.search_declarations(Parameters(SearchDeclarationsParams { + $($field: $val,)* + }))) + }; + } + + macro_rules! get_descendants { + ($server:expr, $($field:ident: $val:expr),* $(,)?) => { + parse(&$server.get_descendants(Parameters(GetDescendantsParams { + $($field: $val,)* + }))) + }; + } + + macro_rules! find_constant_references { + ($server:expr, $($field:ident: $val:expr),* $(,)?) => { + parse(&$server.find_constant_references(Parameters(FindConstantReferencesParams { + $($field: $val,)* + }))) + }; + } + + fn get_declaration(server: &RubydexServer, name: &str) -> Value { + parse(&server.get_declaration(Parameters(GetDeclarationParams { name: name.to_string() }))) + } + + fn get_file_declarations(server: &RubydexServer, file_path: &str) -> Value { + parse(&server.get_file_declarations(Parameters(GetFileDeclarationsParams { + file_path: file_path.to_string(), + }))) + } + + // -- search_declarations -- + + #[test] + fn search_declarations_returns_matching_results() { + let s = server_with_source("class Dog; end"); + let res = search_declarations!(s, query: "Dog".into(), kind: None, limit: None, offset: None); + + assert_includes!(res, "results", "Dog"); + assert_json_int!(res, "total", 1); + + let first = &array!(res, "results")[0]; + assert_eq!(first["name"], "Dog"); + assert_eq!(first["kind"], "Class"); + assert!(first["locations"][0]["path"].as_str().unwrap().ends_with("test.rb")); + assert_json_int!(first["locations"][0], "line", 1); + } + + #[test] + fn search_declarations_kind_filter() { + let s = server_with_source( + " + class Dog; end + module Walkable; end + ", + ); + + let res = search_declarations!(s, query: "Dog".into(), kind: Some("Class".into()), limit: None, offset: None); + assert_includes!(res, "results", "Dog"); + + let res = search_declarations!(s, query: "Dog".into(), kind: Some("Module".into()), limit: None, offset: None); + assert!(array!(res, "results").is_empty()); + + // Case-insensitive + let res = search_declarations!(s, query: "Dog".into(), kind: Some("class".into()), limit: None, offset: None); + assert_includes!(res, "results", "Dog"); + + let res = search_declarations!(s, query: "dog".into(), kind: None, limit: None, offset: None); + assert_includes!(res, "results", "Dog"); + } + + #[test] + fn search_declarations_no_match() { + let s = server_with_source("class Dog; end"); + let res = search_declarations!(s, query: "Zzzzzzzzz".into(), kind: None, limit: None, offset: None); + assert!(array!(res, "results").is_empty()); + assert_json_int!(res, "total", 0); + } + + #[test] + fn search_declarations_pagination() { + let s = server_with_source( + " + class A; end + class B; end + class C; end + ", + ); + + let res = search_declarations!(s, query: String::new(), kind: None, limit: Some(2), offset: Some(0)); + assert_eq!(array!(res, "results").len(), 2); + let total = res["total"].as_u64().unwrap(); + + let res = search_declarations!(s, query: String::new(), kind: None, limit: Some(2), offset: Some(9999)); + assert!(array!(res, "results").is_empty()); + assert_json_int!(res, "total", total); + + // Verify consecutive pages return different items + let page1 = search_declarations!(s, query: String::new(), kind: None, limit: Some(1), offset: Some(0)); + let page2 = search_declarations!(s, query: String::new(), kind: None, limit: Some(1), offset: Some(1)); + let name1 = array!(page1, "results")[0]["name"].as_str().unwrap(); + let name2 = array!(page2, "results")[0]["name"].as_str().unwrap(); + assert_ne!(name1, name2, "Page 1 and page 2 should return different items"); + } + + // -- get_declaration -- + + #[test] + fn get_declaration_class_with_ancestors_and_members() { + let s = server_with_source( + " + class Animal; end + class Dog < Animal + def speak; end + def fetch; end + end + ", + ); + let res = get_declaration(&s, "Dog"); + + assert_eq!(res["name"], "Dog"); + assert_eq!(res["kind"], "Class"); + assert!(!array!(res, "definitions").is_empty()); + assert_includes!(res, "ancestors", "Animal"); + assert_includes!(res, "members", "Dog#speak()"); + assert_includes!(res, "members", "Dog#fetch()"); + + let member = array!(res, "members") + .iter() + .find(|m| m["name"].as_str() == Some("Dog#speak()")) + .unwrap(); + assert_eq!(member["kind"], "Method"); + assert!(member["location"]["path"].as_str().unwrap().ends_with("test.rb")); + assert_json_int!(member["location"], "line", 3); + } + + #[test] + fn get_declaration_module() { + let s = server_with_source("module Greetable; end"); + assert_eq!(get_declaration(&s, "Greetable")["kind"], "Module"); + } + + #[test] + fn get_declaration_doc_comments() { + let s = server_with_source( + " + # The Animal class represents all animals. + class Animal; end + ", + ); + let res = get_declaration(&s, "Animal"); + let comments = array!(res["definitions"][0], "comments"); + assert!( + comments.iter().any(|c| c.as_str().unwrap().contains("Animal")), + "Expected doc comment on Animal, got: {comments:?}" + ); + } + + #[test] + fn get_declaration_mixin_ancestors() { + let s = server_with_source( + " + module Greetable; end + class Person + include Greetable + end + ", + ); + assert_includes!(get_declaration(&s, "Person"), "ancestors", "Greetable"); + } + + #[test] + fn get_declaration_constant() { + let s = server_with_source( + " + class Animal + KINGDOM = 'Animalia' + end + ", + ); + let res = get_declaration(&s, "Animal::KINGDOM"); + assert_eq!(res["kind"], "Constant"); + assert!(array!(res, "ancestors").is_empty()); + assert!(array!(res, "members").is_empty()); + } + + #[test] + fn get_declaration_not_found() { + let s = server_with_source("class Dog; end"); + assert_error( + &s.get_declaration(Parameters(GetDeclarationParams { + name: "DoesNotExist".into(), + })), + "not_found", + ); + } + + // -- get_descendants -- + + #[test] + fn get_descendants_with_subclasses() { + let s = server_with_source( + " + class Animal; end + class Dog < Animal; end + class Cat < Animal; end + ", + ); + + let res = get_descendants!(s, name: "Animal".into(), limit: None, offset: None); + assert_eq!(res["name"], "Animal"); + assert_includes!(res, "descendants", "Animal"); + assert_includes!(res, "descendants", "Dog"); + assert_includes!(res, "descendants", "Cat"); + assert_json_int!(res, "total", 3); + + // Cat: 1 descendant (itself only, no subclasses) + let res = get_descendants!(s, name: "Cat".into(), limit: None, offset: None); + assert_json_int!(res, "total", 1); + } + + #[test] + fn get_descendants_module() { + let s = server_with_source( + " + module Greetable; end + + class Person + include Greetable + end + ", + ); + let res = get_descendants!(s, name: "Greetable".into(), limit: None, offset: None); + assert_includes!(res, "descendants", "Person"); + } + + #[test] + fn get_descendants_inheritance_chain() { + let s = server_with_source( + " + class Foo; end + class Bar < Foo; end + class Baz < Bar; end + ", + ); + let res = get_descendants!(s, name: "Foo".into(), limit: None, offset: None); + assert_includes!(res, "descendants", "Bar"); + assert_includes!(res, "descendants", "Baz"); + } + + #[test] + fn get_descendants_pagination() { + let s = server_with_source( + " + class Animal; end + class Dog < Animal; end + class Cat < Animal; end + ", + ); + let page1 = get_descendants!(s, name: "Animal".into(), limit: Some(1), offset: Some(0)); + assert_eq!(array!(page1, "descendants").len(), 1); + assert_json_int!(page1, "total", 3); + + let page2 = get_descendants!(s, name: "Animal".into(), limit: Some(1), offset: Some(1)); + let name1 = array!(page1, "descendants")[0]["name"].as_str().unwrap(); + let name2 = array!(page2, "descendants")[0]["name"].as_str().unwrap(); + assert_ne!(name1, name2, "Page 1 and page 2 should return different descendants"); + } + + #[test] + fn get_descendants_not_found() { + let s = server_with_source("class Dog; end"); + assert_error( + &s.get_descendants(Parameters(GetDescendantsParams { + name: "DoesNotExist".into(), + limit: None, + offset: None, + })), + "not_found", + ); + } + + #[test] + fn get_descendants_invalid_kind() { + let s = server_with_source( + " + class Animal + KINGDOM = 'Animalia' + end + ", + ); + assert_error( + &s.get_descendants(Parameters(GetDescendantsParams { + name: "Animal::KINGDOM".into(), + limit: None, + offset: None, + })), + "invalid_kind", + ); + } + + // -- find_constant_references -- + + #[test] + fn find_constant_references_success() { + let s = server_with_source( + " + class Animal; end + class Dog < Animal; end + class Kennel + def build + Animal.new + end + end + ", + ); + let res = find_constant_references!(s, name: "Animal".into(), limit: None, offset: None); + + assert_eq!(res["name"], "Animal"); + assert_eq!(array!(res, "references").len(), 2); + assert_json_int!(res, "total", 2); + let first_ref = &array!(res, "references")[0]; + assert!(first_ref["path"].as_str().unwrap().ends_with("test.rb")); + assert_json_int!(first_ref, "line", 2); + assert_json_int!(first_ref, "column", 13); + } + + #[test] + fn find_constant_references_cross_file() { + let models = test_uri("models.rb"); + let services = test_uri("services.rb"); + let s = server_with_sources(&[ + (&models, "class Dog; end"), + ( + &services, + " + class Kennel + def adopt + Dog.new + end + end + ", + ), + ]); + let res = find_constant_references!(s, name: "Dog".into(), limit: None, offset: None); + let paths: Vec<&str> = array!(res, "references") + .iter() + .filter_map(|r| r["path"].as_str()) + .collect(); + assert!( + paths.iter().any(|p| p.contains("services")), + "Expected cross-file ref from services, got: {paths:?}" + ); + } + + #[test] + fn find_constant_references_pagination() { + let s = server_with_source( + " + class Animal; end + class Dog < Animal; end + class Cat < Animal; end + class Kennel + def build + Animal.new + end + end + ", + ); + let full = find_constant_references!(s, name: "Animal".into(), limit: None, offset: None); + let full_total = full["total"].as_u64().unwrap(); + + let page = find_constant_references!(s, name: "Animal".into(), limit: Some(1), offset: Some(0)); + assert_eq!(array!(page, "references").len(), 1); + assert_json_int!(page, "total", full_total); + } + + #[test] + fn find_constant_references_not_found() { + let s = server_with_source("class Dog; end"); + assert_error( + &s.find_constant_references(Parameters(FindConstantReferencesParams { + name: "DoesNotExist".into(), + limit: None, + offset: None, + })), + "not_found", + ); + } + + // -- get_file_declarations -- + + #[test] + fn get_file_declarations_success() { + let s = server_with_source( + " + class Animal; end + class Dog < Animal; end + module Greetable; end + ", + ); + let res = get_file_declarations(&s, "test.rb"); + + assert_includes!(res, "declarations", "Animal"); + assert_includes!(res, "declarations", "Dog"); + assert_includes!(res, "declarations", "Greetable"); + assert_eq!(array!(res, "declarations")[0]["name"], "Animal"); + assert_eq!(array!(res, "declarations")[0]["kind"], "Class"); + assert_json_int!(array!(res, "declarations")[0], "line", 1); + } + + #[test] + fn get_file_declarations_multiple_files() { + let models = test_uri("models.rb"); + let services = test_uri("services.rb"); + let s = server_with_sources(&[(&models, "class Animal; end"), (&services, "class Kennel; end")]); + let res = get_file_declarations(&s, "services.rb"); + assert_includes!(res, "declarations", "Kennel"); + } + + #[test] + fn get_file_declarations_not_found() { + let s = server_with_source("class Dog; end"); + assert_error( + &s.get_file_declarations(Parameters(GetFileDeclarationsParams { + file_path: "nonexistent.rb".into(), + })), + "not_found", + ); + } + + // -- codebase_stats -- + + #[test] + fn codebase_stats_returns_counts() { + let a = test_uri("a.rb"); + let b = test_uri("b.rb"); + let s = server_with_sources(&[(&a, "class Animal; end"), (&b, "module Greetable; end")]); + let res = parse(&s.codebase_stats()); + + assert_eq!(res["files"], 2); + assert_json_int!(res, "declarations", 5); + assert_json_int!(res, "definitions", 2); + + let breakdown = &res["breakdown_by_kind"]; + assert_json_int!(breakdown, "Class", 4); + assert_json_int!(breakdown, "Module", 1); + } + + // -- error states -- + + #[test] + fn returns_indexing_error_when_graph_not_ready() { + let server = RubydexServer::new("/test".to_string()); + // graph is None (still indexing) + assert_error(&server.codebase_stats(), "indexing"); + } + + #[test] + fn returns_indexing_failed_error() { + let server = RubydexServer::new("/test".to_string()); + { + let mut state = server.state.write().unwrap(); + state.error = Some("something went wrong".into()); + } + assert_error(&server.codebase_stats(), "indexing_failed"); + } +} diff --git a/rust/rubydex-mcp/src/tools.rs b/rust/rubydex-mcp/src/tools.rs index a8d9fc32..c7053e74 100644 --- a/rust/rubydex-mcp/src/tools.rs +++ b/rust/rubydex-mcp/src/tools.rs @@ -8,6 +8,8 @@ pub struct SearchDeclarationsParams { pub kind: Option, #[schemars(description = "Maximum number of results to return (default 25, max 100)")] pub limit: Option, + #[schemars(description = "Number of results to skip for pagination (default 0)")] + pub offset: Option, } #[derive(Debug, serde::Deserialize, JsonSchema)] @@ -20,6 +22,10 @@ pub struct GetDeclarationParams { pub struct GetDescendantsParams { #[schemars(description = "Fully qualified name of the class or module")] pub name: String, + #[schemars(description = "Maximum number of descendants to return (default 100, max 500)")] + pub limit: Option, + #[schemars(description = "Number of descendants to skip for pagination (default 0)")] + pub offset: Option, } #[derive(Debug, serde::Deserialize, JsonSchema)] @@ -28,6 +34,8 @@ pub struct FindConstantReferencesParams { pub name: String, #[schemars(description = "Maximum number of references to return (default 50, max 200)")] pub limit: Option, + #[schemars(description = "Number of references to skip for pagination (default 0)")] + pub offset: Option, } #[derive(Debug, serde::Deserialize, JsonSchema)] diff --git a/rust/rubydex-mcp/tests/mcp.rs b/rust/rubydex-mcp/tests/mcp.rs index 895ccdc4..2a2cbb48 100644 --- a/rust/rubydex-mcp/tests/mcp.rs +++ b/rust/rubydex-mcp/tests/mcp.rs @@ -214,16 +214,17 @@ fn mcp_server_e2e() { assert!(stats["declarations"].as_u64().unwrap() > 0); // Semantic query: search declarations. - let results: Vec = serde_json::from_value(call_next_tool( + let search_response = call_next_tool( &mut stdin, &mut reader, &mut request_id, "search_declarations", &json!({ "query": "Dog" }), - )) - .unwrap(); - let result_names = names_from_entries(&results); + ); + let results = search_response["results"].as_array().unwrap(); + let result_names = names_from_entries(results); assert_has_name(&result_names, "Dog", "search results"); + assert!(search_response["total"].as_u64().unwrap() > 0); // Semantic query: inspect declaration details. let decl = call_next_tool( @@ -253,6 +254,7 @@ fn mcp_server_e2e() { let descendant_entries = descendants["descendants"].as_array().unwrap(); let descendant_names = names_from_entries(descendant_entries); assert_has_name(&descendant_names, "Dog", "Animal descendants"); + assert!(descendants["total"].as_u64().unwrap() > 0); // Semantic query: resolved constant references. let references = call_next_tool( @@ -271,6 +273,7 @@ fn mcp_server_e2e() { refs.iter().all(|entry| entry["path"].as_str().is_some()), "Expected references to include file paths, got: {references}" ); + assert!(references["total"].as_u64().unwrap() > 0); // Semantic query: file declarations. let file_declarations = call_next_tool( diff --git a/rust/rubydex/src/test_utils/graph_test.rs b/rust/rubydex/src/test_utils/graph_test.rs index 534822cd..91536e3f 100644 --- a/rust/rubydex/src/test_utils/graph_test.rs +++ b/rust/rubydex/src/test_utils/graph_test.rs @@ -21,6 +21,11 @@ impl GraphTest { &self.graph } + #[must_use] + pub fn into_graph(self) -> Graph { + self.graph + } + /// Indexes a Ruby source pub fn index_uri(&mut self, uri: &str, source: &str) { let source = normalize_indentation(source); From 7a175d1c8644bb1bdd11a0dfececb64149e6cae7 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 5 Mar 2026 17:31:45 +0000 Subject: [PATCH 2/3] Document MCP server pagination and result ordering in architecture.md Add MCP Server section covering the two-pass pagination approach, result ordering guarantees (stable within a session, may vary across restarts due to parallel indexing), and key file references. --- docs/architecture.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 20a7f3f5..6394decb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,6 +108,29 @@ Connections between nodes use hashed IDs defined in `ids.rs`: - `StringId`: ID for interned string values - `ReferenceId`: ID for constant or method reference occurrences (combines reference kind, URI, and offset) +## MCP Server + +The `rubydex-mcp` crate exposes rubydex's code intelligence as MCP tools over stdio JSON-RPC. The server indexes the codebase on startup, then serves tool requests against the immutable graph. + +### Pagination + +Three tools return paginated collections: `search_declarations`, `get_descendants`, and `find_constant_references`. Each accepts `offset` and `limit` parameters and returns a `total` count. + +Pagination uses a two-pass approach: first collect all entries that pass filtering into a `Vec`, then apply `skip(offset).take(limit)`. This ensures `total` accurately reflects the number of results the caller can page through. + +### Result Ordering + +All collection-returning tools iterate over `IdentityHashMap` or `IdentityHashSet` structures. These use a deterministic hasher (xxh3, no random seed), so iteration order is fixed for a given map state. + +- **Within a server session**: Order is consistent between requests as long as the graph has not been re-indexed. Incremental re-indexing (e.g., after a file save) may change the graph between paginated requests, causing items to shift, appear, or disappear. Callers should not assume pagination stability across graph changes. +- **Across server restarts**: Order may change. Indexing is parallelized, so thread scheduling affects insertion order into the graph, which determines HashMap/HashSet bucket layout. + +### Key Files + +- `rubydex-mcp/src/server.rs`: Tool handler implementations and pagination logic +- `rubydex-mcp/src/tools.rs`: Parameter struct definitions with JSON Schema annotations +- `rubydex-mcp/tests/mcp.rs`: Integration tests (full MCP protocol over stdio) + ## FFI Layer The Rust crate exposes a C-compatible FFI API through `rubydex-sys`. The C extension in `ext/rubydex/` wraps this API for Ruby. From 9fc7b280d4f7ae944710ba45690177989ba84139 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Fri, 6 Mar 2026 17:09:40 +0000 Subject: [PATCH 3/3] Add detect_dead_constants MCP tool Find Ruby classes, modules, and constants with zero resolved references across the codebase. Uses rubydex's semantic resolution for high accuracy compared to string-matching approaches. - Params: kind filter, file_path prefix, limit/offset pagination - Response: name, kind, file, line, owner per dead constant - Excludes infrastructure declarations (Object, BasicObject, Module, Class) - Sorted by FQN for deterministic pagination - 13 tests covering classes, modules, constants, aliases, includes, superclass references, kind filtering, pagination, and file path filtering --- rust/rubydex-mcp/src/server.rs | 409 ++++++++++++++++++++++++++++++++- rust/rubydex-mcp/src/tools.rs | 16 ++ rust/rubydex-mcp/tests/mcp.rs | 6 +- 3 files changed, 426 insertions(+), 5 deletions(-) diff --git a/rust/rubydex-mcp/src/server.rs b/rust/rubydex-mcp/src/server.rs index 411b2726..f50cb682 100644 --- a/rust/rubydex-mcp/src/server.rs +++ b/rust/rubydex-mcp/src/server.rs @@ -3,8 +3,8 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use crate::tools::{ - FindConstantReferencesParams, GetDeclarationParams, GetDescendantsParams, GetFileDeclarationsParams, - SearchDeclarationsParams, + DetectDeadConstantsParams, FindConstantReferencesParams, GetDeclarationParams, GetDescendantsParams, + GetFileDeclarationsParams, SearchDeclarationsParams, }; use rmcp::{ ServerHandler, @@ -15,7 +15,7 @@ use rmcp::{ }; use rubydex::model::ids::{DeclarationId, UriId}; use rubydex::model::{ - declaration::{Ancestor, Ancestors}, + declaration::{Ancestor, Ancestors, Declaration}, graph::Graph, }; use url::Url; @@ -180,6 +180,12 @@ fn format_path(uri: &str, root: &Path) -> String { .map_or_else(|_| path.display().to_string(), |rel| rel.display().to_string()) } +/// Returns true for declarations that are part of Ruby's built-in runtime and should never +/// be reported as dead code. +fn is_infrastructure_declaration(name: &str) -> bool { + matches!(name, "Object" | "BasicObject" | "Module" | "Class") +} + /// Formats an ancestor chain into a JSON array of `{"name": ..., "kind": ...}` objects. fn format_ancestors(graph: &Graph, ancestors: &Ancestors) -> Vec { ancestors @@ -441,6 +447,108 @@ impl RubydexServer { serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string()) } + #[tool( + description = "Find Ruby classes, modules, and constants with zero resolved references across the codebase — dead code candidates. Returns declarations that are defined but never referenced anywhere, with file locations and owning namespace. Uses resolved name references (not string matching) for high accuracy. Verify critical results with find_constant_references before deletion. Results are paginated: the response includes `total`." + )] + fn detect_dead_constants(&self, Parameters(params): Parameters) -> String { + let state = ensure_graph_ready!(self); + let graph = state.graph.as_ref().unwrap(); + + let limit = params.limit.filter(|&l| l > 0).unwrap_or(50).min(200); // default 50, max 200 + let offset = params.offset.unwrap_or(0); + let kind_filter = params.kind.as_deref(); + + // Resolve optional file_path to an absolute prefix for matching + let path_prefix = params.file_path.as_ref().map(|fp| { + let p = if Path::new(fp).is_absolute() { + PathBuf::from(fp) + } else { + self.root_path.join(fp) + }; + std::fs::canonicalize(&p).unwrap_or(p) + }); + + // Collect and sort by name for deterministic pagination order. + // Cannot use paginate! here because graph.declarations() is a HashMap + // with non-deterministic iteration order. + let mut filtered: Vec<_> = graph + .declarations() + .values() + .filter(|decl| { + let kind = decl.kind(); + if !matches!(kind, "Class" | "Module" | "Constant" | "ConstantAlias") { + return false; + } + + if let Some(filter) = kind_filter + && !kind.eq_ignore_ascii_case(filter) + { + return false; + } + + if !decl.references().is_empty() { + return false; + } + + if is_infrastructure_declaration(decl.name()) { + return false; + } + + if let Some(prefix) = &path_prefix { + let has_matching_def = decl.definitions().iter().any(|def_id| { + graph + .definitions() + .get(def_id) + .and_then(|def| graph.documents().get(def.uri_id())) + .and_then(|doc| uri_to_path(doc.uri())) + .is_some_and(|p| p.starts_with(prefix)) + }); + if !has_matching_def { + return false; + } + } + + true + }) + .collect(); + filtered.sort_by_key(|decl| decl.name()); + let total = filtered.len(); + + let results: Vec = filtered + .into_iter() + .skip(offset) + .take(limit) + .map(|decl| { + let file_info = decl.definitions().first().and_then(|def_id| { + let def = graph.definitions().get(def_id)?; + let doc = graph.documents().get(def.uri_id())?; + let loc = def.offset().to_location(doc).to_presentation(); + Some((format_path(doc.uri(), &self.root_path), loc.start_line())) + }); + + let owner_name = graph + .declarations() + .get(decl.owner_id()) + .map_or("", Declaration::name); + + serde_json::json!({ + "name": decl.name(), + "kind": decl.kind(), + "file": file_info.as_ref().map(|(f, _)| f.as_str()), + "line": file_info.map(|(_, l)| l), + "owner": owner_name, + }) + }) + .collect(); + + let result = serde_json::json!({ + "results": results, + "total": total, + }); + + serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string()) + } + #[tool( description = "List all Ruby classes, modules, methods, and constants defined in a specific file. Returns a structural overview with names, kinds, and line numbers. Use this to understand a file's structure before reading it, or to see what a file contributes to the codebase. Accepts relative or absolute paths." )] @@ -541,13 +649,14 @@ Decision guide: - Need reverse hierarchy? -> get_descendants (what inherits from this class/module) - Refactoring a class/module/constant? -> find_constant_references (all precise usages across codebase) - Exploring a file? -> get_file_declarations (structural overview) +- Finding dead code? -> detect_dead_constants (unreferenced classes, modules, constants) - Want general statistics? -> codebase_stats (size and composition) Typical workflow: search_declarations -> get_declaration -> find_constant_references. Fully qualified name format: "Foo::Bar" for classes/modules/constants, "Foo::Bar#method_name" for instance methods. -Pagination: search_declarations, get_descendants, and find_constant_references return paginated results. The response includes a `total` count. When `total` exceeds the number of returned items, use `offset` to fetch the next page. +Pagination: search_declarations, get_descendants, find_constant_references, and detect_dead_constants return paginated results. The response includes a `total` count. When `total` exceeds the number of returned items, use `offset` to fetch the next page. Use Grep instead for: literal string search, log messages, comments, non-Ruby files, or content search rather than structural queries."#; @@ -1050,6 +1159,298 @@ mod tests { ); } + // -- detect_dead_constants -- + + /// Extract all "name" strings from a JSON array field. + fn result_names<'a>(json: &'a Value, field: &str) -> Vec<&'a str> { + json[field] + .as_array() + .expect("expected array") + .iter() + .filter_map(|e| e["name"].as_str()) + .collect() + } + + macro_rules! detect_dead_constants { + ($server:expr, $($field:ident: $val:expr),* $(,)?) => { + parse(&$server.detect_dead_constants(Parameters(DetectDeadConstantsParams { + $($field: $val,)* + }))) + }; + } + + #[test] + fn detect_dead_constants_finds_unreferenced_class() { + let s = server_with_source( + " + class LiveClass; end + class DeadClass; end + class Consumer < LiveClass; end + ", + ); + let res = detect_dead_constants!(s, kind: None, file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(names.contains(&"DeadClass"), "DeadClass has no references, got: {names:?}"); + assert!(!names.contains(&"LiveClass"), "LiveClass is referenced by Consumer, got: {names:?}"); + + let entry = array!(res, "results") + .iter() + .find(|e| e["name"].as_str() == Some("DeadClass")) + .unwrap(); + assert_eq!(entry["kind"], "Class"); + assert!(entry["file"].as_str().unwrap().ends_with("test.rb")); + assert!(entry["line"].as_u64().is_some()); + } + + #[test] + fn detect_dead_constants_superclass_counts_as_reference() { + let s = server_with_source( + " + class Base; end + class LiveChild < Base; end + class DeadChild < Base; end + x = LiveChild.new + ", + ); + let res = detect_dead_constants!(s, kind: None, file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(!names.contains(&"Base"), "Base is referenced as superclass, got: {names:?}"); + assert!(!names.contains(&"LiveChild"), "LiveChild is referenced by x, got: {names:?}"); + assert!(names.contains(&"DeadChild"), "DeadChild has no references, got: {names:?}"); + } + + #[test] + fn detect_dead_constants_include_counts_as_reference() { + let s = server_with_source( + " + module LiveModule; end + module DeadModule; end + class User + include LiveModule + end + ", + ); + let res = detect_dead_constants!(s, kind: None, file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(!names.contains(&"LiveModule"), "LiveModule is included by User, got: {names:?}"); + assert!(names.contains(&"DeadModule"), "DeadModule has no references, got: {names:?}"); + } + + #[test] + fn detect_dead_constants_finds_unreferenced_constant_alias() { + let s = server_with_source( + " + class Original; end + LiveAlias = Original + DeadAlias = Original + x = LiveAlias.new + ", + ); + let res = detect_dead_constants!(s, kind: None, file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(names.contains(&"DeadAlias"), "DeadAlias has no references, got: {names:?}"); + assert!(!names.contains(&"LiveAlias"), "LiveAlias is referenced by x, got: {names:?}"); + assert!(!names.contains(&"Original"), "Original is referenced by aliases, got: {names:?}"); + } + + #[test] + fn detect_dead_constants_finds_unreferenced_module() { + let s = server_with_source( + " + module LiveModule; end + module DeadModule; end + class Foo + include LiveModule + end + ", + ); + let res = detect_dead_constants!(s, kind: None, file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(names.contains(&"DeadModule"), "DeadModule has no references, got: {names:?}"); + assert!(!names.contains(&"LiveModule"), "LiveModule is included by Foo, got: {names:?}"); + } + + #[test] + fn detect_dead_constants_finds_unreferenced_constant() { + let s = server_with_source( + " + class Holder + LIVE_CONST = 1 + DEAD_CONST = 2 + end + x = Holder::LIVE_CONST + ", + ); + let res = detect_dead_constants!(s, kind: None, file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(names.contains(&"Holder::DEAD_CONST"), "DEAD_CONST has no references, got: {names:?}"); + assert!(!names.contains(&"Holder::LIVE_CONST"), "LIVE_CONST is referenced by x, got: {names:?}"); + } + + #[test] + fn detect_dead_constants_excludes_infrastructure() { + let s = server_with_source( + " + class DeadClass; end + class LiveClass; end + x = LiveClass.new + ", + ); + let res = detect_dead_constants!(s, kind: None, file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(names.contains(&"DeadClass"), "DeadClass should appear, got: {names:?}"); + assert!( + names.iter().all(|n| !is_infrastructure_declaration(n)), + "Infrastructure declarations should be excluded, got: {names:?}" + ); + } + + #[test] + fn detect_dead_constants_excludes_methods_and_variables() { + let s = server_with_source( + " + class Foo + def unused_method; end + LIVE_CONST = 1 + DEAD_CONST = 2 + end + x = Foo::LIVE_CONST + ", + ); + let res = detect_dead_constants!(s, kind: None, file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(names.contains(&"Foo::DEAD_CONST"), "DEAD_CONST should appear, got: {names:?}"); + assert!(!names.contains(&"Foo::LIVE_CONST"), "LIVE_CONST is referenced, got: {names:?}"); + assert!( + !names.iter().any(|n| n.contains("unused_method")), + "Methods should never appear, got: {names:?}" + ); + } + + #[test] + fn detect_dead_constants_kind_filter() { + let s = server_with_source( + " + class LiveClass; end + class DeadClass; end + module DeadModule; end + x = LiveClass.new + ", + ); + + let res = detect_dead_constants!(s, kind: Some("Class".into()), file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + assert!(names.contains(&"DeadClass"), "DeadClass should appear with kind=Class, got: {names:?}"); + assert!( + !names.contains(&"DeadModule"), + "Module should be filtered out when kind=Class, got: {names:?}" + ); + + // Case-insensitive + let res = detect_dead_constants!(s, kind: Some("module".into()), file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + assert!(names.contains(&"DeadModule"), "DeadModule should appear with kind=module, got: {names:?}"); + assert!(!names.contains(&"DeadClass"), "Class should be filtered out when kind=module, got: {names:?}"); + } + + #[test] + fn detect_dead_constants_pagination() { + let s = server_with_source( + " + class LiveClass; end + class DeadA; end + class DeadB; end + class DeadC; end + x = LiveClass.new + ", + ); + + let full = detect_dead_constants!(s, kind: Some("Class".into()), file_path: None, limit: None, offset: None); + let total = full["total"].as_u64().unwrap(); + assert!(total >= 3, "Expected at least 3 dead classes, got {total}"); + let all_names = result_names(&full, "results"); + assert!(!all_names.contains(&"LiveClass"), "LiveClass should not appear, got: {all_names:?}"); + + let page1 = detect_dead_constants!(s, kind: Some("Class".into()), file_path: None, limit: Some(1), offset: Some(0)); + assert_eq!(array!(page1, "results").len(), 1); + assert_json_int!(page1, "total", total); + + let page2 = detect_dead_constants!(s, kind: Some("Class".into()), file_path: None, limit: Some(1), offset: Some(1)); + let name1 = array!(page1, "results")[0]["name"].as_str().unwrap(); + let name2 = array!(page2, "results")[0]["name"].as_str().unwrap(); + assert_ne!(name1, name2, "Pages should return different items"); + } + + #[test] + fn detect_dead_constants_file_path_filter() { + let models = test_uri("app/models/user.rb"); + let services = test_uri("app/services/notifier.rb"); + let s = server_with_sources(&[ + (&models, "class DeadUser; end\nclass LiveUser; end"), + ( + &services, + "class DeadNotifier; end\nx = LiveUser", + ), + ]); + + let res = detect_dead_constants!(s, kind: None, file_path: Some("app/models".into()), limit: None, offset: None); + let names = result_names(&res, "results"); + assert!(names.contains(&"DeadUser"), "DeadUser is in app/models and dead, got: {names:?}"); + assert!(!names.contains(&"LiveUser"), "LiveUser is referenced from services, got: {names:?}"); + assert!( + !names.contains(&"DeadNotifier"), + "DeadNotifier is in app/services, not app/models, got: {names:?}" + ); + } + + #[test] + fn detect_dead_constants_all_referenced() { + let s = server_with_source( + " + class Animal; end + class Dog < Animal; end + Dog.new + ", + ); + let res = detect_dead_constants!(s, kind: Some("Class".into()), file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(!names.contains(&"Animal"), "Animal is referenced by Dog, got: {names:?}"); + assert!(!names.contains(&"Dog"), "Dog is referenced by Dog.new, got: {names:?}"); + } + + #[test] + fn detect_dead_constants_includes_owner() { + let s = server_with_source( + " + class Outer + class LiveInner; end + class DeadInner; end + end + x = Outer::LiveInner.new + ", + ); + let res = detect_dead_constants!(s, kind: None, file_path: None, limit: None, offset: None); + let names = result_names(&res, "results"); + + assert!(names.contains(&"Outer::DeadInner"), "DeadInner has no references, got: {names:?}"); + assert!(!names.contains(&"Outer::LiveInner"), "LiveInner is referenced by x, got: {names:?}"); + + let entry = array!(res, "results") + .iter() + .find(|e| e["name"].as_str() == Some("Outer::DeadInner")) + .unwrap(); + assert_eq!(entry["owner"].as_str().unwrap(), "Outer"); + } + // -- get_file_declarations -- #[test] diff --git a/rust/rubydex-mcp/src/tools.rs b/rust/rubydex-mcp/src/tools.rs index c7053e74..0b2df2af 100644 --- a/rust/rubydex-mcp/src/tools.rs +++ b/rust/rubydex-mcp/src/tools.rs @@ -38,6 +38,22 @@ pub struct FindConstantReferencesParams { pub offset: Option, } +#[derive(Debug, serde::Deserialize, JsonSchema)] +pub struct DetectDeadConstantsParams { + #[schemars( + description = "Filter by declaration kind: \"Class\", \"Module\", \"Constant\", \"ConstantAlias\" (case-insensitive). Omit to include all." + )] + pub kind: Option, + #[schemars( + description = "Relative or absolute file path prefix to limit results (e.g. \"app/models\" matches all files under that directory)" + )] + pub file_path: Option, + #[schemars(description = "Maximum number of results to return (default 50, max 200)")] + pub limit: Option, + #[schemars(description = "Number of results to skip for pagination (default 0)")] + pub offset: Option, +} + #[derive(Debug, serde::Deserialize, JsonSchema)] pub struct GetFileDeclarationsParams { #[schemars(description = "File path (relative or absolute) to list declarations for")] diff --git a/rust/rubydex-mcp/tests/mcp.rs b/rust/rubydex-mcp/tests/mcp.rs index 2a2cbb48..eaf573ca 100644 --- a/rust/rubydex-mcp/tests/mcp.rs +++ b/rust/rubydex-mcp/tests/mcp.rs @@ -131,7 +131,11 @@ fn assert_tools_are_registered(stdin: &mut impl Write, reader: &mut BufReader