use std::str::FromStr;
use rmcp::{
handler::server::wrapper::Parameters,
model::CallToolResult,
schemars::{self, JsonSchema},
tool, tool_router, ErrorData,
};
use serde::{Deserialize, Serialize};
use vestige_config::{embeddings_config_for, traces_config_for};
use vestige_core::{resolve_default_mode, MemoryType, ScoredCard, SearchMode};
use vestige_embed::{build_provider, EmbeddingProvider};
use vestige_engine::error::EngineError;
use vestige_engine::Caller;
use crate::server::{err, ok_json, Inner, VestigeServer};
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct SearchParams {
pub query: String,
#[serde(default)]
pub mode: Option<String>,
#[serde(default = "default_search_limit")]
pub limit: u32,
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub include_score_parts: Option<bool>,
}
fn default_search_limit() -> u32 {
8
}
#[derive(Debug, Serialize)]
struct SearchEnvelope<'a> {
mode: &'static str,
results: &'a [ScoredCard],
warnings: Vec<String>,
}
#[tool_router(router = search_router, vis = "pub(crate)")]
impl VestigeServer {
#[tool(
description = "Search project memory. Three modes: lexical (BM25 over text, default — \
always available), semantic (cosine over embeddings; requires \
`vestige embed --all` first), hybrid (merged, with score breakdown; \
falls back to lexical with a warning when no embeddings exist). \
Returns compact memory cards; use vestige_expand for full content."
)]
pub async fn vestige_search(
&self,
Parameters(p): Parameters<SearchParams>,
) -> Result<CallToolResult, ErrorData> {
let inner = self.inner.lock().await;
if let Some(ref mode_str) = p.mode {
SearchMode::from_str(mode_str)
.map_err(|e| err("INVALID_MODE", e.to_string(), false))?;
}
let config_default = inner
.config
.search
.as_ref()
.and_then(|s| s.default_mode.as_deref());
let mode = resolve_default_mode(p.mode.as_deref(), config_default).map_err(|e| {
err(
"INVALID_CONFIG",
format!("invalid [search] default_mode: {e}"),
false,
)
})?;
let type_filter = p
.r#type
.as_deref()
.map(MemoryType::from_str)
.transpose()
.map_err(|e| err("INVALID_TYPE", e.to_string(), false))?;
let traces_cfg = traces_config_for(inner.config.traces.as_ref());
let outcome = match mode {
SearchMode::Lexical => vestige_engine::search::search_lexical(
&inner.store,
&inner.project_id,
&p.query,
type_filter,
p.limit,
Caller::Mcp,
&traces_cfg,
)
.map_err(engine_err_to_data)?,
SearchMode::Semantic => {
let provider = build_configured_provider(&inner)?;
let status = inner
.store
.embedding_status(&inner.project_id)
.map_err(|e| err("STORE_FAILED", e.to_string(), true))?;
if status.embedded_representations == 0 {
return Err(err(
"EMBEDDINGS_UNAVAILABLE",
"No embeddings found for this project. Run `vestige embed --all` first.",
false,
));
}
if let Some(msg) =
vestige_engine::search::provider_mismatch_message(&status, provider.as_ref())
{
return Err(err("EMBEDDINGS_UNAVAILABLE", msg, false));
}
vestige_engine::search::search_semantic(
&inner.store,
&inner.project_id,
&p.query,
type_filter,
p.limit,
provider.as_ref(),
Caller::Mcp,
&traces_cfg,
)
.map_err(engine_err_to_data)?
}
SearchMode::Hybrid => {
let provider = build_configured_provider(&inner)?;
vestige_engine::search::search_hybrid(
&inner.store,
&inner.project_id,
&p.query,
type_filter,
p.limit,
provider.as_ref(),
Caller::Mcp,
&traces_cfg,
)
.map_err(engine_err_to_data)?
}
};
let envelope = SearchEnvelope {
mode: outcome.effective_mode.as_str(),
results: &outcome.scored,
warnings: outcome.warnings,
};
ok_json(&envelope)
}
}
fn engine_err_to_data(e: EngineError) -> ErrorData {
match e {
EngineError::Store(_) => err("STORE_FAILED", e.to_string(), true),
EngineError::Embed(_) => err("EMBED_FAILED", e.to_string(), false),
EngineError::EmbeddingsUnavailable(_) => {
err("EMBEDDINGS_UNAVAILABLE", e.to_string(), false)
}
EngineError::CandidateNotFound { .. } => err("CANDIDATE_NOT_FOUND", e.to_string(), false),
EngineError::CandidateNotPending { .. } => {
err("CANDIDATE_NOT_PENDING", e.to_string(), false)
}
EngineError::OutOfScope => err("OUT_OF_SCOPE", e.to_string(), false),
EngineError::Validation { .. } => err("VALIDATION_ERROR", e.to_string(), false),
EngineError::Core(_) => err("CORE_ERROR", e.to_string(), false),
EngineError::TraceNotFound { .. } => err("TRACE_NOT_FOUND", e.to_string(), false),
}
}
fn build_configured_provider(inner: &Inner) -> Result<Box<dyn EmbeddingProvider>, ErrorData> {
let cfg = embeddings_config_for(inner.config.embeddings.as_ref());
build_provider(&cfg).map_err(|e| err("PROVIDER_INIT_FAILED", e.to_string(), false))
}