use serde_json::Value;
use super::{
types::{require_str, DispatchError},
McpServer,
};
pub(super) async fn dispatch_search_tool(
server: &McpServer,
tool: &str,
args: &Value,
) -> Option<Result<Value, DispatchError>> {
match tool {
"search_lexical" => Some(server.run_lane_search(args, SearchLane::Lexical).await),
"search_semantic" => Some(server.run_lane_search(args, SearchLane::Semantic).await),
"search_kg" => Some(server.run_lane_search(args, SearchLane::Graph).await),
"search_all" => {
if server.resolve_index_id(args).is_some() {
return Some(server.run_lane_search(args, SearchLane::All).await);
}
let query = match require_str(args, "query") {
Ok(q) => q,
Err(e) => return Some(Err(e)),
};
let top_k = args.get("top_k").and_then(Value::as_u64).unwrap_or(10);
let full_content = args
.get("full_content")
.and_then(Value::as_bool)
.unwrap_or(false);
let body = serde_json::json!({
"query": query,
"top_k": top_k,
"full_content": full_content,
});
Some(server.post("/search", &body).await)
}
"search" => {
let index_id = match server.resolve_index_id(args) {
Some(v) => v,
None => {
return Some(Err(DispatchError::InvalidParams(
"missing required string field: index_id".into(),
)))
}
};
let body = match args.get("query") {
Some(v @ Value::Object(_)) => v.clone(),
Some(Value::String(text)) => {
let mut b = serde_json::json!({ "text": text });
if let Some(k) = args.get("top_k").and_then(Value::as_u64) {
b["top_k"] = Value::from(k);
}
if let Some(bf) = args.get("branch_files") {
b["branch_files"] = bf.clone();
}
if let Some(bb) = args.get("branch_boost") {
b["branch_boost"] = bb.clone();
}
if let Some(br) = args.get("branch").and_then(Value::as_str) {
b["branch"] = Value::String(br.to_string());
}
if let Some(m) = args.get("mode").and_then(Value::as_str) {
b["mode"] = Value::String(m.to_string());
}
if let Some(ea) = args.get("exclude_archived").and_then(Value::as_bool) {
b["exclude_archived"] = Value::Bool(ea);
}
b
}
_ => {
return Some(Err(DispatchError::InvalidParams(
"missing or invalid 'query' (expected string or object)".into(),
)))
}
};
let resp = match server
.post(&format!("/indexes/{index_id}/search"), &body)
.await
{
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
let query_text = body.get("text").and_then(Value::as_str).unwrap_or_default();
let log_intent = resp
.get("intent")
.and_then(Value::as_str)
.unwrap_or("Unknown");
let log_latency = resp.get("latency_ms").and_then(Value::as_u64).unwrap_or(0);
let log_results = resp
.get("results")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
tracing::info!(
index_id = %index_id,
intent = %log_intent,
latency_ms = log_latency,
results = log_results,
query = %&query_text[..query_text.len().min(80)],
"search"
);
Some(Ok(resp))
}
"search_similar" => {
let index_id = args
.get("index")
.and_then(Value::as_str)
.unwrap_or("default");
let file = match require_str(args, "file") {
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
let mut body = serde_json::json!({ "file": file });
if let Some(func) = args.get("function").and_then(Value::as_str) {
body["function"] = Value::String(func.to_string());
}
if let Some(k) = args.get("top_k").and_then(Value::as_u64) {
body["top_k"] = Value::from(k);
}
Some(
server
.post(&format!("/indexes/{index_id}/search_similar"), &body)
.await,
)
}
_ => None,
}
}
impl McpServer {
pub(super) async fn run_lane_search(
&self,
args: &Value,
lane: SearchLane,
) -> Result<Value, DispatchError> {
let index_id = self.resolve_index_id(args).ok_or_else(|| {
DispatchError::InvalidParams("missing required string field: index_id".into())
})?;
let query_text = require_str(args, "query")?;
if let Some(required) = lane.required_capability() {
let status = self.get(&format!("/indexes/{index_id}/status")).await?;
let caps: Vec<String> = status
.get("search_capabilities")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
if !caps.iter().any(|c| c == required) {
let stages = status
.get("stages")
.cloned()
.unwrap_or(Value::Object(Default::default()));
let stages_summary = summarise_stages(&stages);
let suggested = lane.suggested_fallback_tools(&caps);
let suggested_list = suggested.join(", ");
let stage_label = lane.stage_label();
let message = format!(
"{tool} requires Stage {stage_num} ({stage_name}), which is not yet \
ready on index '{index_id}'. Current stages: {stages_summary}. \
Suggested: use {suggested_list}, or wait for the reindex to complete.",
tool = lane.tool_name(),
stage_num = lane.stage_number(),
stage_name = stage_label,
);
return Err(DispatchError::StageNotReady {
message,
current_stages: stages,
suggested_tools: suggested,
});
}
}
let mut body = match args.get("query") {
Some(v @ Value::Object(_)) => v.clone(),
_ => serde_json::json!({ "text": query_text }),
};
if let Some(stage_str) = lane.stage_serde_value() {
body["stage"] = Value::String(stage_str.into());
} else {
body.as_object_mut().map(|m| m.remove("stage"));
}
body["expand_graph"] = Value::Bool(lane.expand_graph_default());
if let Some(k) = args.get("top_k").and_then(Value::as_u64) {
body["top_k"] = Value::from(k);
}
if let Some(bf) = args.get("branch_files") {
body["branch_files"] = bf.clone();
}
if let Some(bb) = args.get("branch_boost") {
body["branch_boost"] = bb.clone();
}
if let Some(br) = args.get("branch").and_then(Value::as_str) {
body["branch"] = Value::String(br.to_string());
}
if let Some(m) = args.get("mode").and_then(Value::as_str) {
body["mode"] = Value::String(m.to_string());
}
if let Some(ea) = args.get("exclude_archived").and_then(Value::as_bool) {
body["exclude_archived"] = Value::Bool(ea);
}
if let Some(rq) = args.get("refine_query").and_then(Value::as_str) {
body["refine_query"] = Value::String(rq.to_string());
}
let resp = self
.post(&format!("/indexes/{index_id}/search"), &body)
.await?;
let log_intent = resp
.get("intent")
.and_then(Value::as_str)
.unwrap_or("Unknown");
let log_latency = resp.get("latency_ms").and_then(Value::as_u64).unwrap_or(0);
let log_results = resp
.get("results")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let tool_name = lane.tool_name();
tracing::info!(
tool = %tool_name,
index_id = %index_id,
intent = %log_intent,
latency_ms = log_latency,
results = log_results,
query = %&query_text[..query_text.len().min(80)],
"search"
);
Ok(resp)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SearchLane {
Lexical,
Semantic,
Graph,
All,
}
impl SearchLane {
pub(super) fn tool_name(self) -> &'static str {
match self {
SearchLane::Lexical => "search_lexical",
SearchLane::Semantic => "search_semantic",
SearchLane::Graph => "search_kg",
SearchLane::All => "search_all",
}
}
pub(super) fn stage_serde_value(self) -> Option<&'static str> {
match self {
SearchLane::Lexical => Some("lexical"),
SearchLane::Semantic => Some("semantic"),
SearchLane::Graph => Some("graph"),
SearchLane::All => None,
}
}
pub(super) fn expand_graph_default(self) -> bool {
matches!(self, SearchLane::Graph | SearchLane::All)
}
pub(super) fn required_capability(self) -> Option<&'static str> {
match self {
SearchLane::Lexical | SearchLane::All => None,
SearchLane::Semantic => Some("vector"),
SearchLane::Graph => Some("kg"),
}
}
pub(super) fn stage_number(self) -> u8 {
match self {
SearchLane::Lexical => 1,
SearchLane::Semantic => 2,
SearchLane::Graph => 3,
SearchLane::All => 0,
}
}
pub(super) fn stage_label(self) -> &'static str {
match self {
SearchLane::Lexical => "lexical",
SearchLane::Semantic => "embeddings",
SearchLane::Graph => "symbol graph",
SearchLane::All => "all",
}
}
pub(super) fn suggested_fallback_tools(self, caps: &[String]) -> Vec<&'static str> {
let has_vector = caps.iter().any(|c| c == "vector");
match self {
SearchLane::Semantic => vec!["search_lexical", "search_all"],
SearchLane::Graph => {
if has_vector {
vec!["search_semantic", "search_lexical", "search_all"]
} else {
vec!["search_lexical", "search_all"]
}
}
SearchLane::Lexical | SearchLane::All => vec!["search_lexical"],
}
}
}
pub(super) fn summarise_stages(stages: &Value) -> String {
let mut parts: Vec<String> = Vec::with_capacity(3);
for key in ["lexical", "semantic", "graph"] {
let status = stages
.get(key)
.and_then(|s| s.get("status"))
.and_then(Value::as_str)
.unwrap_or("unknown");
let pretty = status
.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<String>();
parts.push(format!("{key}={pretty}"));
}
parts.join(", ")
}