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:?}" + ); + } + } +}