🧪 test(kebab-mcp): tools/list returns 4 tools (fb-30)

Approach: extracted `pub fn build_tools_vec() -> Vec<Tool>` from the
inline `list_tools` trait impl body — `RequestContext<RoleServer>` 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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-07 16:04:42 +09:00
parent bc16dbf12a
commit 61eef9bc82
2 changed files with 98 additions and 22 deletions

View File

@@ -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<Tool> {
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::<tools::search::SearchInput>(),
),
Tool::new(
"ask",
"RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.",
schema_for_type::<tools::ask::AskInput>(),
),
]
}
#[derive(Clone)]
pub struct KebabHandler {
state: KebabAppState,
@@ -49,28 +78,7 @@ impl ServerHandler for KebabHandler {
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
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::<tools::search::SearchInput>(),
),
Tool::new(
"ask",
"RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.",
schema_for_type::<tools::ask::AskInput>(),
),
]))
Ok(ListToolsResult::with_all_items(build_tools_vec()))
}
async fn call_tool(

View File

@@ -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<JsonObject> (serde_json::Map<String, Value>).
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:?}"
);
}
}
}