use serde_json::Value;
use super::tests::req;
use super::{error_codes, McpServer};
#[test]
fn summarise_stages_renders_in_order() {
use super::search::summarise_stages;
let stages = serde_json::json!({
"lexical": { "status": "ready" },
"semantic": { "status": "in_progress" },
"graph": { "status": "pending" },
});
let s = summarise_stages(&stages);
assert_eq!(s, "lexical=Ready, semantic=InProgress, graph=Pending");
}
#[tokio::test]
async fn tools_list_returns_five_search_tools() {
let server = McpServer::new("http://127.0.0.1:1");
let resp = server.dispatch(req("tools/list", Value::Null)).await;
let result = resp.result.expect("expected result");
let tools = result
.get("tools")
.and_then(Value::as_array)
.expect("array");
let names: Vec<&str> = tools
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.collect();
for required in [
"search",
"search_lexical",
"search_semantic",
"search_kg",
"search_all",
] {
assert!(
names.contains(&required),
"tools/list missing '{required}' (got {names:?})"
);
}
let search_tools: Vec<&str> = names
.iter()
.copied()
.filter(|n| *n == "search" || n.starts_with("search_"))
.collect();
let lane_tools: Vec<&str> = names
.iter()
.copied()
.filter(|n| {
matches!(
*n,
"search" | "search_lexical" | "search_semantic" | "search_kg" | "search_all"
)
})
.collect();
assert_eq!(
lane_tools.len(),
5,
"expected exactly 5 lane-related search tools, got {lane_tools:?} (all: {search_tools:?})"
);
}
#[tokio::test]
async fn per_lane_tool_descriptions_carry_when_to_use_hooks() {
let server = McpServer::new("http://127.0.0.1:1");
let resp = server.dispatch(req("tools/list", Value::Null)).await;
let result = resp.result.expect("expected result");
let tools = result
.get("tools")
.and_then(Value::as_array)
.expect("array");
for (name, hook) in [
("search_lexical", "exact symbol name"),
("search_semantic", "by meaning"),
("search_kg", "from a known seed"),
("search_all", "When in doubt"),
] {
let tool = tools
.iter()
.find(|t| t.get("name").and_then(Value::as_str) == Some(name))
.unwrap_or_else(|| panic!("tool {name} missing"));
let desc = tool["description"].as_str().expect("description");
assert!(
desc.contains(hook),
"tool {name} description must mention '{hook}': {desc}"
);
}
}
#[tokio::test]
async fn per_lane_tools_require_index_id_and_query() {
let server = McpServer::new("http://127.0.0.1:1");
for tool in ["search_lexical", "search_semantic", "search_kg"] {
let resp = server.dispatch(req(tool, serde_json::json!({}))).await;
let err = resp.error.expect("expected error");
assert_eq!(
err.code,
error_codes::INVALID_PARAMS,
"{tool} must reject empty args"
);
}
}
#[tokio::test]
async fn search_all_without_index_id_calls_global_fanout_endpoint() {
use axum::routing::post;
use axum::{Json, Router};
use std::sync::Arc;
use tokio::sync::Mutex;
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let captured_clone = Arc::clone(&captured);
async fn fanout_handler(
axum::extract::State(captured): axum::extract::State<Arc<Mutex<Vec<String>>>>,
Json(_body): Json<Value>,
) -> Json<Value> {
captured.lock().await.push("/search".into());
Json(serde_json::json!({ "results": [] }))
}
let app = Router::new()
.route("/search", post(fanout_handler))
.with_state(captured_clone);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let server = McpServer::new(format!("http://{addr}"));
let resp = server
.dispatch(req(
"search_all",
serde_json::json!({ "query": "anything" }),
))
.await;
assert!(resp.error.is_none());
assert_eq!(captured.lock().await.as_slice(), &["/search".to_string()]);
}
fn schema_of<'a>(tools: &'a Value, name: &str) -> &'a Value {
tools
.as_array()
.expect("descriptors array")
.iter()
.find(|t| t.get("name").and_then(Value::as_str) == Some(name))
.unwrap_or_else(|| panic!("tool {name} missing"))
.get("inputSchema")
.expect("inputSchema")
}
#[test]
fn tool_descriptors_pinned_none_is_unchanged() {
use super::descriptors::{tool_descriptors, tool_descriptors_pinned};
assert_eq!(tool_descriptors_pinned(None), tool_descriptors());
}
#[test]
fn pinned_descriptors_make_index_id_optional() {
use super::descriptors::tool_descriptors_pinned;
let pinned = tool_descriptors_pinned(Some("trusty-tools"));
let schema = schema_of(&pinned, "search");
let required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.expect("required")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(
!required.contains(&"index_id"),
"index_id should not be required when pinned: {required:?}"
);
assert!(
required.contains(&"query"),
"query stays required: {required:?}"
);
}
#[test]
fn pinned_descriptors_annotate_index_id() {
use super::descriptors::tool_descriptors_pinned;
let pinned = tool_descriptors_pinned(Some("trusty-tools"));
let schema = schema_of(&pinned, "search");
let desc = schema
.get("properties")
.and_then(|p| p.get("index_id"))
.and_then(|i| i.get("description"))
.and_then(Value::as_str)
.expect("index_id description");
assert!(
desc.contains("trusty-tools") && desc.contains("pinned"),
"index_id description should name the pinned default: {desc:?}"
);
assert!(
!desc.contains(".."),
"annotated description must not contain a double-period: {desc:?}"
);
assert!(
desc.contains(". Defaults to this session's pinned project index"),
"note must follow exactly one period+space: {desc:?}"
);
}
#[tokio::test]
async fn tools_list_reflects_session_pin() {
let server = McpServer::new("http://127.0.0.1:1").with_pinned_index("trusty-tools");
let resp = server.dispatch(req("tools/list", Value::Null)).await;
let tools = resp
.result
.expect("result")
.get("tools")
.cloned()
.expect("tools");
let schema = schema_of(&tools, "search");
let required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.expect("required")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(
!required.contains(&"index_id"),
"pin must reach tools/list: {required:?}"
);
}