use crate::mcp::param_names;
use crate::mcp::registry::McpTool;
use crate::models::Tier;
use crate::{db, validate};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct SearchRequest {
pub query: String,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub tier: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
#[serde(default)]
pub agent_id: Option<String>,
#[schemars(description = "#151 scope-visibility agent.")]
#[serde(default)]
pub as_agent: Option<String>,
#[serde(default)]
pub include_archived: Option<bool>,
#[serde(default)]
pub format: Option<String>,
}
#[allow(dead_code)]
pub struct SearchTool;
impl McpTool for SearchTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_SEARCH
}
fn description() -> &'static str {
"Search memories by exact keyword match (AND semantics)."
}
fn docs() -> &'static str {
"Exact keyword AND search. Deterministic; no fuzzy/semantic. Filters: namespace, tier, agent_id, as_agent (Task 1.5 scope). WT-1-E: atomised sources hidden by default."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<SearchRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Core.name()
}
}
pub(super) fn handle_search(
conn: &rusqlite::Connection,
params: &Value,
caller: Option<&str>,
) -> Result<Value, String> {
let query = params["query"].as_str();
let namespace = params["namespace"].as_str();
let tier = params["tier"].as_str().and_then(Tier::from_str);
let limit = usize::try_from(params["limit"].as_u64().unwrap_or(20)).unwrap_or(usize::MAX);
let agent_id = params["agent_id"].as_str();
if let Some(aid) = agent_id {
validate::validate_agent_id(aid).map_err(|e| e.to_string())?;
}
let as_agent = params["as_agent"].as_str();
if let Some(a) = as_agent {
validate::validate_namespace(a).map_err(|e| e.to_string())?;
}
let include_archived = params["include_archived"].as_bool().unwrap_or(false);
let source_uri = params[param_names::SOURCE_URI]
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(uri) = source_uri {
validate::validate_source_uri(uri).map_err(|e| e.to_string())?;
}
if query.unwrap_or("").trim().is_empty() {
if let Some(uri) = source_uri {
let results =
db::list_by_source_uri(conn, uri, namespace, Some(limit.min(200)), as_agent)
.map_err(|e| e.to_string())?;
let results = filter_visible(results, caller);
return Ok(json!({"results": results, "count": results.len()}));
}
return Err(crate::errors::msg::QUERY_REQUIRED.into());
}
let results = db::search_with_source_uri(
conn,
query.unwrap_or(""),
namespace,
tier.as_ref(),
limit.min(200),
None,
None,
None,
None,
agent_id,
as_agent,
include_archived,
source_uri,
)
.map_err(|e| e.to_string())?;
let results = filter_visible(results, caller);
Ok(json!({"results": results, "count": results.len()}))
}
fn filter_visible(
results: Vec<crate::models::Memory>,
caller: Option<&str>,
) -> Vec<crate::models::Memory> {
match caller {
Some(c) => results
.into_iter()
.filter(|m| crate::visibility::is_visible_to_caller(m, c))
.collect(),
None => results,
}
}
#[cfg(test)]
mod visibility_1468_tests {
use super::*;
use crate::models::{Memory, Tier as MTier};
use crate::storage as db;
fn fresh_conn() -> rusqlite::Connection {
db::open(std::path::Path::new(":memory:")).expect("open in-memory db")
}
fn mem(title: &str, agent: &str, scope: Option<&str>) -> Memory {
let now = chrono::Utc::now().to_rfc3339();
let metadata = match scope {
Some(s) => json!({crate::META_KEY_AGENT_ID: agent, crate::META_KEY_SCOPE: s}),
None => json!({crate::META_KEY_AGENT_ID: agent}),
};
Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: MTier::Mid,
namespace: "ns".to_string(),
title: title.to_string(),
content: "needle in the haystack".to_string(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
}
}
fn seed(conn: &rusqlite::Connection) {
use crate::models::namespace::MemoryScope;
db::insert(conn, &mem("priv", "ai:alice", None)).expect("ins");
db::insert(
conn,
&mem("shared", "ai:bob", Some(MemoryScope::Collective.as_str())),
)
.expect("ins");
}
#[test]
fn caller_none_returns_all() {
let conn = fresh_conn();
seed(&conn);
let out = handle_search(&conn, &json!({"query": "needle"}), None).expect("ok");
assert_eq!(out["count"].as_u64(), Some(2));
}
#[test]
fn non_owner_excludes_cross_agent_private() {
let conn = fresh_conn();
seed(&conn);
let out = handle_search(&conn, &json!({"query": "needle"}), Some("ai:carol")).expect("ok");
assert_eq!(out["count"].as_u64(), Some(1));
assert_eq!(out["results"][0]["title"], "shared");
}
#[test]
fn owner_sees_own_private_and_shared() {
let conn = fresh_conn();
seed(&conn);
let out = handle_search(&conn, &json!({"query": "needle"}), Some("ai:alice")).expect("ok");
assert_eq!(out["count"].as_u64(), Some(2));
}
#[test]
fn source_uri_only_branch_excludes_cross_agent_private() {
let uri = format!(
"{}atlas/doc-1",
crate::validate::VALID_SOURCE_URI_SCHEMES[0]
);
let conn = fresh_conn();
let mut priv_row = mem("priv", "ai:alice", None);
priv_row.source_uri = Some(uri.clone());
let mut shared_row = mem("shared", "ai:bob", Some("collective"));
shared_row.source_uri = Some(uri.clone());
db::insert(&conn, &priv_row).expect("ins");
db::insert(&conn, &shared_row).expect("ins");
let out = handle_search(&conn, &json!({"source_uri": uri}), Some("ai:carol")).expect("ok");
assert_eq!(out["count"].as_u64(), Some(1));
assert_eq!(out["results"][0]["title"], "shared");
let all = handle_search(&conn, &json!({"source_uri": uri}), None).expect("ok");
assert_eq!(all["count"].as_u64(), Some(2));
}
}
#[cfg(test)]
mod d1_4_985_tests {
use super::*;
use crate::mcp::d1_4_985_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn memory_search_parity_985() {
let derived = derived_props_for::<SearchRequest>();
assert_property_set_parity("memory_search", &derived);
assert_descriptions_match("memory_search", &derived);
}
#[test]
fn memory_search_tool_metadata_985() {
assert_eq!(SearchTool::name(), "memory_search");
assert_eq!(SearchTool::family(), "core");
}
}