use std::sync::Arc;
use adk_rust::tool::FunctionTool;
use adk_rust::{Tool, ToolContext};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::tools::SharedToolCtx;
use crate::discover::scope::Scope;
use crate::search::types::SearchMode;
use crate::search::unified::{unified_search, SearchCtx, UnifiedSearchRequest};
fn search_ctx_from_shared(shared: &SharedToolCtx) -> SearchCtx {
SearchCtx {
catalog: shared.catalog.clone(),
format: shared.format.clone(),
node_id: None,
pool: shared.pool.clone().map(Arc::new),
tenant: pensieve_core::tenant::DEFAULT_TENANT,
allowed_databases: None,
}
}
async fn run_unified(shared: &SharedToolCtx, req: UnifiedSearchRequest, request_id: &str) -> Value {
let ctx = search_ctx_from_shared(shared);
match unified_search(&ctx, req, request_id).await {
Ok(resp) => serde_json::to_value(&resp)
.unwrap_or_else(|e| json!({"error": format!("serialize: {e}")})),
Err(resp) => json!({
"error": format!("search failed: HTTP {}", resp.status().as_u16()),
}),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct SearchArgs {
query: String,
#[serde(default)]
scope: Option<Value>,
#[serde(default)]
limit: Option<usize>,
}
fn parse_scope(raw: Option<Value>) -> Result<Option<Scope>, String> {
match raw {
None | Some(Value::Null) => Ok(None),
Some(v) => serde_json::from_value(v)
.map(Some)
.map_err(|e| format!("scope: {e}")),
}
}
const SEARCH_DESC: &str = "Hybrid lexical+vector search across data sources \
(the shared /v1/search substrate, data mode). One broad query fans out over \
every source in scope, running a keyword leg and (where a vector column \
exists) a semantic leg, fused by RRF. Returns ranked hits carrying `db.table` \
provenance + the matched row — follow up with run_kql / run_sql to correlate. \
Scope with `{\"kind\":\"sources\",\"sources\":[\"db.*\"]}` or omit for all sources.";
pub fn tool_search(ctx: SharedToolCtx) -> Arc<dyn Tool> {
let shared = ctx;
Arc::new(
FunctionTool::new(
"search",
SEARCH_DESC,
move |_tc: Arc<dyn ToolContext>, args: Value| {
let shared = shared.clone();
async move {
let parsed: SearchArgs = match serde_json::from_value(args) {
Ok(v) => v,
Err(e) => return Ok(json!({"error": format!("args: {e}")})),
};
let scope = match parse_scope(parsed.scope) {
Ok(s) => s,
Err(e) => return Ok(json!({"error": e})),
};
let req = UnifiedSearchRequest {
query: parsed.query,
mode: SearchMode::Data,
scope,
limit: parsed.limit,
..Default::default()
};
Ok(run_unified(&shared, req, "mcp-search").await)
}
},
)
.with_parameters_schema::<SearchArgs>()
.with_read_only(true)
.with_concurrency_safe(true),
)
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct GraphSearchArgs {
query: String,
#[serde(default)]
graph: Option<String>,
#[serde(default)]
labels: Option<Vec<String>>,
#[serde(default)]
limit: Option<usize>,
}
const GRAPH_SEARCH_DESC: &str = "Search graph nodes by text/label across one \
or all graphs (shared substrate, graph mode). Pass `graph` to target one \
named graph, or omit it to search every graph across all databases; `labels` \
filters by node label. Returns ranked nodes with `<db>/<graph>` provenance, \
the node id, and a title — follow node ids with graph_traverse for a deeper \
subgraph.";
pub fn tool_graph_search(ctx: SharedToolCtx) -> Arc<dyn Tool> {
let shared = ctx;
Arc::new(
FunctionTool::new(
"graph_search",
GRAPH_SEARCH_DESC,
move |_tc: Arc<dyn ToolContext>, args: Value| {
let shared = shared.clone();
async move {
let parsed: GraphSearchArgs = match serde_json::from_value(args) {
Ok(v) => v,
Err(e) => return Ok(json!({"error": format!("args: {e}")})),
};
let req = UnifiedSearchRequest {
query: parsed.query,
mode: SearchMode::Graph,
graph: parsed.graph,
labels: parsed.labels,
limit: parsed.limit,
..Default::default()
};
Ok(run_unified(&shared, req, "mcp-graph-search").await)
}
},
)
.with_parameters_schema::<GraphSearchArgs>()
.with_read_only(true)
.with_concurrency_safe(true),
)
}
#[cfg(test)]
mod tests {
use super::*;
use adk_rust::tool::SimpleToolContext;
use std::sync::Arc;
async fn empty_shared() -> SharedToolCtx {
use pensieve_core::segment_format::SegmentFormat;
let catalog: Arc<dyn pensieve_core::catalog::Catalog> = Arc::new(
pensieve_catalog_sqlite::SqliteCatalog::connect_in_memory()
.await
.expect("in-memory catalog"),
);
let tmp = std::env::temp_dir().join(format!("pensieve-searchtool-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let store = pensieve_storage::build_object_store(&pensieve_storage::StorageConfig::Local {
root: tmp.to_string_lossy().to_string(),
})
.unwrap();
let format: Arc<dyn SegmentFormat> =
Arc::new(pensieve_format_tlm::TelemetryFormat::new(store, "test"));
SharedToolCtx {
realm_scope: Default::default(),
consumer_sink: None,
federation: None,
catalog,
format,
pool: None,
memory: None,
hitl: None,
memory_settings_path: None,
}
}
#[test]
fn search_ctx_uses_agent_defaults() {
let rt = tokio::runtime::Runtime::new().unwrap();
let shared = rt.block_on(empty_shared());
let ctx = search_ctx_from_shared(&shared);
assert_eq!(ctx.tenant, pensieve_core::tenant::DEFAULT_TENANT);
assert!(
ctx.allowed_databases.is_none(),
"no RBAC allow-list ⇒ all pass"
);
assert!(ctx.node_id.is_none());
assert!(ctx.pool.is_none());
}
#[tokio::test]
async fn search_tool_constructs_with_name_desc_schema() {
let shared = empty_shared().await;
let tool = tool_search(shared);
assert_eq!(tool.name(), "search");
assert!(tool.description().contains("data mode"));
assert!(tool.parameters_schema().is_some());
}
#[tokio::test]
async fn graph_search_tool_constructs_with_name_desc_schema() {
let shared = empty_shared().await;
let tool = tool_graph_search(shared);
assert_eq!(tool.name(), "graph_search");
assert!(tool.description().contains("graph mode"));
assert!(tool.parameters_schema().is_some());
}
#[tokio::test]
async fn search_returns_wellformed_empty_result_over_empty_catalog() {
let shared = empty_shared().await;
let tool = tool_search(shared);
let tc = Arc::new(SimpleToolContext::new("test"));
let out = tool
.execute(tc, json!({ "query": "anything", "limit": 5 }))
.await
.expect("tool runs");
assert!(out.get("error").is_none(), "no error: {out}");
assert_eq!(out["hits"], json!([]));
assert_eq!(out["sources_searched"], json!(0));
assert!(out.get("elapsed_ms").is_some());
}
#[tokio::test]
async fn graph_search_returns_wellformed_empty_result_over_empty_catalog() {
let shared = empty_shared().await;
let tool = tool_graph_search(shared);
let tc = Arc::new(SimpleToolContext::new("test"));
let out = tool
.execute(tc, json!({ "query": "anything" }))
.await
.expect("tool runs");
assert!(out.get("error").is_none(), "no error: {out}");
assert_eq!(out["hits"], json!([]));
assert_eq!(out["sources_searched"], json!(0));
assert_eq!(out["mode"], json!("graph"));
}
#[tokio::test]
async fn graph_search_seeded_graph_returns_node_hits() {
use pensieve_core::catalog::{GraphSpec, TableConfig};
let shared = empty_shared().await;
let catalog = shared.catalog.clone();
let format = shared.format.clone();
let db_id = catalog.create_database("kg").await.expect("create db kg");
let schema = Arc::new(arrow_schema::Schema::new(vec![
arrow_schema::Field::new("id", arrow_schema::DataType::Utf8, false),
arrow_schema::Field::new("labels", arrow_schema::DataType::Utf8, true),
arrow_schema::Field::new("name", arrow_schema::DataType::Utf8, true),
arrow_schema::Field::new("realm", arrow_schema::DataType::Utf8, true),
]));
catalog
.create_table(db_id, "kg_nodes", schema.clone(), TableConfig::default())
.await
.expect("create kg_nodes");
catalog
.create_table(db_id, "kg_edges", schema.clone(), TableConfig::default())
.await
.expect("create kg_edges");
let tref = catalog
.lookup_table("kg", "kg_nodes")
.await
.expect("lookup kg_nodes");
let ndjson = r#"{"id":"svc:alpha","labels":"Service","name":"alpha-service","realm":"kg"}
{"id":"svc:beta","labels":"Service","name":"beta-service","realm":"kg"}"#;
let batches = pensieve_ingest_core::parse_ndjson(ndjson.as_bytes(), tref.schema.clone())
.expect("parse ndjson");
pensieve_ingest_core::WritePath::new(catalog.clone(), format.clone())
.ingest("kg", &tref, batches)
.await
.expect("ingest node rows");
let mut spec = GraphSpec::with_defaults("kg_nodes", "kg_edges");
spec.realm_col = Some("realm".into());
catalog
.create_graph("kg", "kg", spec)
.await
.expect("create_graph kg");
let tool = tool_graph_search(shared);
let tc = Arc::new(SimpleToolContext::new("test"));
let out = tool
.execute(tc, json!({ "query": "alpha", "graph": "kg" }))
.await
.expect("tool runs");
assert!(out.get("error").is_none(), "no error: {out}");
assert_eq!(out["mode"], json!("graph"));
let hits = out["hits"].as_array().expect("hits array");
assert!(
hits.iter().any(|h| h["id"] == json!("svc:alpha")),
"expected svc:alpha node hit, got {out}"
);
let alpha = hits.iter().find(|h| h["id"] == json!("svc:alpha")).unwrap();
assert_eq!(alpha["kind"], json!("node"));
assert_eq!(alpha["source"], json!("kg/kg"));
assert_eq!(alpha["title"], json!("alpha-service"));
}
}