From 61eef9bc82bf30b6a14fddb75f6f8ecc629bbb5f Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:04:42 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20test(kebab-mcp):=20tools/list=20?= =?UTF-8?q?returns=204=20tools=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach: extracted `pub fn build_tools_vec() -> Vec` from the inline `list_tools` trait impl body — `RequestContext` is non-constructible from outside rmcp (Peer::new is pub(crate)), so a direct trait-method call was not viable without an in-memory transport rmcp 1.6 does not expose. The helper is the single source of truth; `list_tools` now delegates to it. Three test cases in tests/tools_list.rs: - 4 tools present with correct names - search inputSchema has "required": ["query"] - schema/doctor tools accept empty input (type=object, no required) Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/lib.rs | 52 ++++++++++++--------- crates/kebab-mcp/tests/tools_list.rs | 68 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 crates/kebab-mcp/tests/tools_list.rs diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index 1bca86b..9cbafa9 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -23,6 +23,35 @@ pub mod state; pub mod tools; pub use state::KebabAppState; +/// Build the canonical list of tools exposed by the MCP server. +/// +/// Extracted from [`ServerHandler::list_tools`] so it can be called +/// directly in tests without constructing a `RequestContext`. +pub fn build_tools_vec() -> Vec { + vec![ + Tool::new( + "schema", + "Introspection — wire schemas, capabilities, model versions, index stats.", + schema_for_empty_input(), + ), + Tool::new( + "doctor", + "Health check — verifies config, storage, models, and Ollama connectivity.", + schema_for_empty_input(), + ), + Tool::new( + "search", + "Full-text / vector / hybrid search over the knowledge base. Returns search_hit.v1 array.", + schema_for_type::(), + ), + Tool::new( + "ask", + "RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.", + schema_for_type::(), + ), + ] +} + #[derive(Clone)] pub struct KebabHandler { state: KebabAppState, @@ -49,28 +78,7 @@ impl ServerHandler for KebabHandler { _request: Option, _context: RequestContext, ) -> Result { - Ok(ListToolsResult::with_all_items(vec![ - Tool::new( - "schema", - "Introspection — wire schemas, capabilities, model versions, index stats.", - schema_for_empty_input(), - ), - Tool::new( - "doctor", - "Health check — verifies config, storage, models, and Ollama connectivity.", - schema_for_empty_input(), - ), - Tool::new( - "search", - "Full-text / vector / hybrid search over the knowledge base. Returns search_hit.v1 array.", - schema_for_type::(), - ), - Tool::new( - "ask", - "RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.", - schema_for_type::(), - ), - ])) + Ok(ListToolsResult::with_all_items(build_tools_vec())) } async fn call_tool( diff --git a/crates/kebab-mcp/tests/tools_list.rs b/crates/kebab-mcp/tests/tools_list.rs new file mode 100644 index 0000000..f7c0cd4 --- /dev/null +++ b/crates/kebab-mcp/tests/tools_list.rs @@ -0,0 +1,68 @@ +//! Integration: `build_tools_vec` returns 4 tools with correct names and +//! inputSchema. Uses the extracted `pub fn build_tools_vec()` helper — no +//! transport or RequestContext needed. + +use kebab_mcp::build_tools_vec; + +#[test] +fn tools_list_returns_four_tools() { + let tools = build_tools_vec(); + assert_eq!(tools.len(), 4, "expected exactly 4 tools, got {}", tools.len()); + + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"schema"), "missing 'schema' tool"); + assert!(names.contains(&"doctor"), "missing 'doctor' tool"); + assert!(names.contains(&"search"), "missing 'search' tool"); + assert!(names.contains(&"ask"), "missing 'ask' tool"); +} + +#[test] +fn search_tool_input_schema_has_required_query() { + let tools = build_tools_vec(); + let search = tools + .iter() + .find(|t| t.name.as_ref() == "search") + .expect("search tool must be present"); + + // input_schema is Arc (serde_json::Map). + let schema_val = serde_json::Value::Object(search.input_schema.as_ref().clone()); + + let required = schema_val + .get("required") + .and_then(|v| v.as_array()) + .expect("search inputSchema must have a 'required' array"); + + assert!( + required.iter().any(|v| v.as_str() == Some("query")), + "search inputSchema 'required' must contain 'query', got: {required:?}" + ); +} + +#[test] +fn schema_and_doctor_tools_accept_empty_input() { + let tools = build_tools_vec(); + + for name in &["schema", "doctor"] { + let tool = tools + .iter() + .find(|t| t.name.as_ref() == *name) + .unwrap_or_else(|| panic!("{name} tool must be present")); + + let schema_val = serde_json::Value::Object(tool.input_schema.as_ref().clone()); + // An empty-input schema has type "object" and no required fields + // (or no 'required' key at all). + let ty = schema_val.get("type").and_then(|v| v.as_str()); + assert_eq!( + ty, + Some("object"), + "{name} inputSchema 'type' must be 'object', got {ty:?}" + ); + + if let Some(required) = schema_val.get("required").and_then(|v| v.as_array()) { + assert!( + required.is_empty(), + "{name} inputSchema 'required' must be empty, got: {required:?}" + ); + } + } +}