feat: support multi-query memories search (#21004)

## Why
The memories MCP `search` tool only accepts a single substring today,
which makes it hard for clients to express combined queries or explain
why a line matched. This change adds the richer search shape needed for
the next client iteration while keeping the legacy single-`query` call
working.

## What changed
- accept either the legacy `query` field or a new `queries` array, plus
`match_mode: any|all`
- teach the local memories backend to evaluate multi-query line matches
and return `matched_queries` on each hit
- update the MCP input/output schema and add coverage for parser
behavior, ordering, pagination, case sensitivity, and match modes

## Testing
- added unit coverage in `memories/mcp/src/local_tests.rs` and
`memories/mcp/src/server.rs`
This commit is contained in:
jif-oai
2026-05-04 15:55:06 +02:00
committed by GitHub
Unverified
parent 5512b23c95
commit 8ba294ea13
5 changed files with 376 additions and 124 deletions
+14 -3
View File
@@ -1,3 +1,4 @@
use serde::Deserialize;
use serde::Serialize;
use std::future::Future;
@@ -63,7 +64,8 @@ pub struct ReadMemoryResponse {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchMemoriesRequest {
pub query: String,
pub queries: Vec<String>,
pub match_mode: SearchMatchMode,
pub path: Option<String>,
pub cursor: Option<String>,
pub context_lines: usize,
@@ -73,13 +75,21 @@ pub struct SearchMemoriesRequest {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SearchMemoriesResponse {
pub query: String,
pub queries: Vec<String>,
pub match_mode: SearchMatchMode,
pub path: Option<String>,
pub matches: Vec<MemorySearchMatch>,
pub next_cursor: Option<String>,
pub truncated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchMatchMode {
Any,
All,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MemoryEntry {
pub path: String,
@@ -99,6 +109,7 @@ pub struct MemorySearchMatch {
pub match_line_number: usize,
pub content_start_line_number: usize,
pub content: String,
pub matched_queries: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
@@ -115,7 +126,7 @@ pub enum MemoriesBackendError {
LineOffsetExceedsFileLength,
#[error("path '{path}' is not a file")]
NotFile { path: String },
#[error("query must not be empty")]
#[error("queries must not be empty or contain empty strings")]
EmptyQuery,
#[error("I/O error while reading memories: {0}")]
Io(#[from] std::io::Error),
+63 -18
View File
@@ -10,11 +10,13 @@ use crate::backend::MemoryEntryType;
use crate::backend::MemorySearchMatch;
use crate::backend::ReadMemoryRequest;
use crate::backend::ReadMemoryResponse;
use crate::backend::SearchMatchMode;
use crate::backend::SearchMemoriesRequest;
use crate::backend::SearchMemoriesResponse;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
use std::borrow::Cow;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
@@ -185,8 +187,12 @@ impl MemoriesBackend for LocalMemoriesBackend {
&self,
request: SearchMemoriesRequest,
) -> Result<SearchMemoriesResponse, MemoriesBackendError> {
let query = request.query.trim();
if query.is_empty() {
let queries = request
.queries
.iter()
.map(|query| query.trim().to_string())
.collect::<Vec<_>>();
if queries.is_empty() || queries.iter().any(std::string::String::is_empty) {
return Err(MemoriesBackendError::EmptyQuery);
}
@@ -200,7 +206,8 @@ impl MemoriesBackend for LocalMemoriesBackend {
};
let Some(metadata) = Self::metadata_or_none(&start).await? else {
return Ok(SearchMemoriesResponse {
query: request.query,
queries,
match_mode: request.match_mode,
path: request.path,
matches: Vec::new(),
next_cursor: None,
@@ -209,14 +216,15 @@ impl MemoriesBackend for LocalMemoriesBackend {
};
reject_symlink(&display_relative_path(&self.root, &start), &metadata)?;
let matcher =
SearchMatcher::new(queries.clone(), request.match_mode, request.case_sensitive);
let mut matches = Vec::new();
search_entries(
&self.root,
&start,
&metadata,
query,
&matcher,
request.context_lines,
request.case_sensitive,
&mut matches,
)
.await?;
@@ -235,7 +243,8 @@ impl MemoriesBackend for LocalMemoriesBackend {
let next_cursor = (end_index < matches.len()).then(|| end_index.to_string());
let truncated = next_cursor.is_some();
Ok(SearchMemoriesResponse {
query: request.query,
queries,
match_mode: request.match_mode,
path: request.path,
matches: matches.drain(start_index..end_index).collect(),
next_cursor,
@@ -248,13 +257,12 @@ async fn search_entries(
root: &Path,
current: &Path,
current_metadata: &std::fs::Metadata,
query: &str,
matcher: &SearchMatcher,
context_lines: usize,
case_sensitive: bool,
matches: &mut Vec<MemorySearchMatch>,
) -> Result<(), MemoriesBackendError> {
if current_metadata.is_file() {
search_file(root, current, query, context_lines, case_sensitive, matches).await?;
search_file(root, current, matcher, context_lines, matches).await?;
return Ok(());
}
if !current_metadata.is_dir() {
@@ -273,7 +281,7 @@ async fn search_entries(
if metadata.is_dir() {
pending.push(path);
} else if metadata.is_file() {
search_file(root, &path, query, context_lines, case_sensitive, matches).await?;
search_file(root, &path, matcher, context_lines, matches).await?;
}
}
}
@@ -284,9 +292,8 @@ async fn search_entries(
async fn search_file(
root: &Path,
path: &Path,
query: &str,
matcher: &SearchMatcher,
context_lines: usize,
case_sensitive: bool,
matches: &mut Vec<MemorySearchMatch>,
) -> Result<(), MemoriesBackendError> {
let content = match tokio::fs::read_to_string(path).await {
@@ -295,13 +302,9 @@ async fn search_file(
Err(err) => return Err(err.into()),
};
let lines = content.lines().collect::<Vec<_>>();
let normalized_query = (!case_sensitive).then(|| query.to_lowercase());
for (idx, line) in lines.iter().enumerate() {
let is_match = match normalized_query.as_deref() {
Some(query) => line.to_lowercase().contains(query),
None => line.contains(query),
};
if is_match {
let matched_queries = matcher.matched_queries(line);
if !matched_queries.is_empty() {
let start_index = idx.saturating_sub(context_lines);
let end_index = idx
.saturating_add(context_lines)
@@ -312,12 +315,54 @@ async fn search_file(
match_line_number: idx + 1,
content_start_line_number: start_index + 1,
content: lines[start_index..end_index].join("\n"),
matched_queries,
});
}
}
Ok(())
}
struct SearchMatcher {
queries: Vec<String>,
normalized_queries: Option<Vec<String>>,
match_mode: SearchMatchMode,
}
impl SearchMatcher {
fn new(queries: Vec<String>, match_mode: SearchMatchMode, case_sensitive: bool) -> Self {
let normalized_queries = (!case_sensitive).then(|| {
queries
.iter()
.map(|query| query.to_lowercase())
.collect::<Vec<_>>()
});
Self {
queries,
normalized_queries,
match_mode,
}
}
fn matched_queries(&self, line: &str) -> Vec<String> {
let line = match self.normalized_queries.as_ref() {
Some(_) => Cow::Owned(line.to_lowercase()),
None => Cow::Borrowed(line),
};
let queries = self.normalized_queries.as_deref().unwrap_or(&self.queries);
let mut matched_queries = Vec::new();
for (idx, query) in queries.iter().enumerate() {
if line.as_ref().contains(query) {
matched_queries.push(self.queries[idx].clone());
}
}
match self.match_mode {
SearchMatchMode::Any => matched_queries,
SearchMatchMode::All if matched_queries.len() == self.queries.len() => matched_queries,
SearchMatchMode::All => Vec::new(),
}
}
}
async fn read_sorted_dir_paths(dir_path: &Path) -> Result<Vec<PathBuf>, MemoriesBackendError> {
let mut dir = match tokio::fs::read_dir(dir_path).await {
Ok(dir) => dir,
+146 -74
View File
@@ -8,6 +8,18 @@ fn backend(tempdir: &TempDir) -> LocalMemoriesBackend {
LocalMemoriesBackend::from_memory_root(tempdir.path())
}
fn search_request(queries: &[&str]) -> SearchMemoriesRequest {
SearchMemoriesRequest {
queries: queries.iter().map(|query| (*query).to_string()).collect(),
match_mode: SearchMatchMode::Any,
path: None,
cursor: None,
context_lines: 0,
case_sensitive: true,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
}
}
#[tokio::test]
async fn list_returns_shallow_memory_paths() {
let tempdir = TempDir::new().expect("tempdir");
@@ -381,14 +393,7 @@ async fn search_supports_directory_and_file_scopes() {
.expect("write rollout summary");
let response = backend(&tempdir)
.search(SearchMemoriesRequest {
query: "needle".to_string(),
path: None,
cursor: None,
context_lines: 0,
case_sensitive: true,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
})
.search(search_request(&["needle"]))
.await
.expect("search all memories");
assert_eq!(
@@ -399,27 +404,24 @@ async fn search_supports_directory_and_file_scopes() {
match_line_number: 2,
content_start_line_number: 2,
content: "needle".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "rollout_summaries/a.jsonl".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "needle again".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
assert_eq!(response.next_cursor, None);
assert_eq!(response.truncated, false);
let mut request = search_request(&["needle"]);
request.path = Some("MEMORY.md".to_string());
let file_response = backend(&tempdir)
.search(SearchMemoriesRequest {
query: "needle".to_string(),
path: Some("MEMORY.md".to_string()),
cursor: None,
context_lines: 0,
case_sensitive: true,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
})
.search(request)
.await
.expect("search one memory file");
assert_eq!(
@@ -429,6 +431,7 @@ async fn search_supports_directory_and_file_scopes() {
match_line_number: 2,
content_start_line_number: 2,
content: "needle".to_string(),
matched_queries: vec!["needle".to_string()],
}]
);
assert_eq!(file_response.next_cursor, None);
@@ -451,15 +454,10 @@ async fn search_supports_pagination() {
.await
.expect("write rollout summary");
let mut page1_request = search_request(&["needle"]);
page1_request.max_results = 2;
let page1 = backend(&tempdir)
.search(SearchMemoriesRequest {
query: "needle".to_string(),
path: None,
cursor: None,
context_lines: 0,
case_sensitive: true,
max_results: 2,
})
.search(page1_request)
.await
.expect("search first page");
assert_eq!(
@@ -470,27 +468,25 @@ async fn search_supports_pagination() {
match_line_number: 1,
content_start_line_number: 1,
content: "needle one".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 2,
content: "needle two".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
assert_eq!(page1.next_cursor.as_deref(), Some("2"));
assert_eq!(page1.truncated, true);
let mut page2_request = search_request(&["needle"]);
page2_request.cursor = page1.next_cursor;
page2_request.max_results = 2;
let page2 = backend(&tempdir)
.search(SearchMemoriesRequest {
query: "needle".to_string(),
path: None,
cursor: page1.next_cursor,
context_lines: 0,
case_sensitive: true,
max_results: 2,
})
.search(page2_request)
.await
.expect("search second page");
assert_eq!(
@@ -500,12 +496,52 @@ async fn search_supports_pagination() {
match_line_number: 1,
content_start_line_number: 1,
content: "needle three".to_string(),
matched_queries: vec!["needle".to_string()],
}]
);
assert_eq!(page2.next_cursor, None);
assert_eq!(page2.truncated, false);
}
#[tokio::test]
async fn search_preserves_global_lexicographic_path_order() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::create_dir_all(tempdir.path().join("a"))
.await
.expect("create nested dir");
tokio::fs::write(tempdir.path().join("a/child.md"), "needle in child\n")
.await
.expect("write nested file");
tokio::fs::write(tempdir.path().join("a.txt"), "needle in sibling\n")
.await
.expect("write sibling file");
let response = backend(&tempdir)
.search(search_request(&["needle"]))
.await
.expect("search memories");
assert_eq!(
response.matches,
vec![
MemorySearchMatch {
path: "a.txt".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "needle in sibling".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "a/child.md".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "needle in child".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
}
#[tokio::test]
async fn search_supports_context_lines() {
let tempdir = TempDir::new().expect("tempdir");
@@ -516,15 +552,10 @@ async fn search_supports_context_lines() {
.await
.expect("write memory file");
let mut request = search_request(&["needle"]);
request.context_lines = 1;
let response = backend(&tempdir)
.search(SearchMemoriesRequest {
query: "needle".to_string(),
path: None,
cursor: None,
context_lines: 1,
case_sensitive: true,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
})
.search(request)
.await
.expect("search with context");
@@ -536,12 +567,14 @@ async fn search_supports_context_lines() {
match_line_number: 2,
content_start_line_number: 1,
content: "alpha\nneedle\nomega".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 4,
content_start_line_number: 3,
content: "omega\nneedle again".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
@@ -555,14 +588,7 @@ async fn search_supports_case_insensitive_matching() {
.expect("write memory file");
let sensitive_response = backend(&tempdir)
.search(SearchMemoriesRequest {
query: "needle".to_string(),
path: None,
cursor: None,
context_lines: 0,
case_sensitive: true,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
})
.search(search_request(&["needle"]))
.await
.expect("search with case-sensitive matching");
assert_eq!(
@@ -572,18 +598,14 @@ async fn search_supports_case_insensitive_matching() {
match_line_number: 2,
content_start_line_number: 2,
content: "needle".to_string(),
matched_queries: vec!["needle".to_string()],
}]
);
let mut request = search_request(&["needle"]);
request.case_sensitive = false;
let insensitive_response = backend(&tempdir)
.search(SearchMemoriesRequest {
query: "needle".to_string(),
path: None,
cursor: None,
context_lines: 0,
case_sensitive: false,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
})
.search(request)
.await
.expect("search with case-insensitive matching");
assert_eq!(
@@ -594,23 +616,85 @@ async fn search_supports_case_insensitive_matching() {
match_line_number: 1,
content_start_line_number: 1,
content: "Needle".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 2,
content: "needle".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 3,
content_start_line_number: 3,
content: "NEEDLE".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
}
#[tokio::test]
async fn search_supports_any_and_all_match_modes() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(
tempdir.path().join("MEMORY.md"),
"alpha needle beta\nalpha only\nneedle only\n",
)
.await
.expect("write memory file");
let any_response = backend(&tempdir)
.search(search_request(&["alpha", "needle"]))
.await
.expect("search with any match mode");
assert_eq!(
any_response.matches,
vec![
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "alpha needle beta".to_string(),
matched_queries: vec!["alpha".to_string(), "needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 2,
content: "alpha only".to_string(),
matched_queries: vec!["alpha".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 3,
content_start_line_number: 3,
content: "needle only".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
let mut request = search_request(&["alpha", "needle"]);
request.match_mode = SearchMatchMode::All;
let all_response = backend(&tempdir)
.search(request)
.await
.expect("search with all match mode");
assert_eq!(
all_response.matches,
vec![MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "alpha needle beta".to_string(),
matched_queries: vec!["alpha".to_string(), "needle".to_string()],
}]
);
}
#[tokio::test]
async fn search_rejects_invalid_cursor() {
let tempdir = TempDir::new().expect("tempdir");
@@ -618,32 +702,20 @@ async fn search_rejects_invalid_cursor() {
.await
.expect("write memory file");
let mut request = search_request(&["needle"]);
request.cursor = Some("bogus".to_string());
let err = backend(&tempdir)
.search(SearchMemoriesRequest {
query: "needle".to_string(),
path: None,
cursor: Some("bogus".to_string()),
context_lines: 0,
case_sensitive: true,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
})
.search(request)
.await
.expect_err("cursor should be rejected");
assert!(matches!(err, MemoriesBackendError::InvalidCursor { .. }));
let mut request = search_request(&["needle"]);
request.cursor = Some("2".to_string());
let past_end_err = backend(&tempdir)
.search(SearchMemoriesRequest {
query: "needle".to_string(),
path: None,
cursor: Some("2".to_string()),
context_lines: 0,
case_sensitive: true,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
})
.search(request)
.await
.expect_err("cursor past end should be rejected");
assert!(matches!(
past_end_err,
MemoriesBackendError::InvalidCursor { .. }
+46 -15
View File
@@ -71,17 +71,40 @@ pub(crate) fn read_output_schema() -> JsonObject {
pub(crate) fn search_input_schema() -> JsonObject {
json_schema(json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"path": { "type": "string" },
"cursor": { "type": "string" },
"context_lines": { "type": "integer", "minimum": 0 },
"case_sensitive": { "type": "boolean" },
"max_results": { "type": "integer", "minimum": 1 }
},
"required": ["query"],
"additionalProperties": false
"anyOf": [
{
"type": "object",
"properties": {
"query": { "type": "string" },
"match_mode": { "type": "string", "enum": ["any", "all"] },
"path": { "type": "string" },
"cursor": { "type": "string" },
"context_lines": { "type": "integer", "minimum": 0 },
"case_sensitive": { "type": "boolean" },
"max_results": { "type": "integer", "minimum": 1 }
},
"required": ["query"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"queries": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
},
"match_mode": { "type": "string", "enum": ["any", "all"] },
"path": { "type": "string" },
"cursor": { "type": "string" },
"context_lines": { "type": "integer", "minimum": 0 },
"case_sensitive": { "type": "boolean" },
"max_results": { "type": "integer", "minimum": 1 }
},
"required": ["queries"],
"additionalProperties": false
}
]
}))
}
@@ -89,7 +112,11 @@ pub(crate) fn search_output_schema() -> JsonObject {
json_schema(json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"queries": {
"type": "array",
"items": { "type": "string" }
},
"match_mode": { "type": "string", "enum": ["any", "all"] },
"path": {
"anyOf": [{ "type": "string" }, { "type": "null" }]
},
@@ -104,15 +131,19 @@ pub(crate) fn search_output_schema() -> JsonObject {
"path": { "type": "string" },
"match_line_number": { "type": "integer" },
"content_start_line_number": { "type": "integer" },
"content": { "type": "string" }
"content": { "type": "string" },
"matched_queries": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["path", "match_line_number", "content_start_line_number", "content"],
"required": ["path", "match_line_number", "content_start_line_number", "content", "matched_queries"],
"additionalProperties": false
}
},
"truncated": { "type": "boolean" }
},
"required": ["query", "path", "matches", "next_cursor", "truncated"],
"required": ["queries", "match_mode", "path", "matches", "next_cursor", "truncated"],
"additionalProperties": false
}))
}
+107 -14
View File
@@ -7,6 +7,7 @@ use crate::backend::MAX_SEARCH_RESULTS;
use crate::backend::MemoriesBackend;
use crate::backend::MemoriesBackendError;
use crate::backend::ReadMemoryRequest;
use crate::backend::SearchMatchMode;
use crate::backend::SearchMemoriesRequest;
use crate::local::LocalMemoriesBackend;
use crate::schema;
@@ -55,7 +56,9 @@ struct ReadArgs {
#[derive(Deserialize)]
struct SearchArgs {
query: String,
query: Option<String>,
queries: Option<Vec<String>>,
match_mode: Option<SearchMatchMode>,
path: Option<String>,
cursor: Option<String>,
context_lines: Option<usize>,
@@ -144,20 +147,10 @@ impl<B: MemoriesBackend> ServerHandler for MemoriesMcpServer<B> {
}
SEARCH_TOOL_NAME => {
let args: SearchArgs = parse_args(value)?;
let request = args.into_request()?;
json!(
self.backend
.search(SearchMemoriesRequest {
query: args.query,
path: args.path,
cursor: args.cursor,
context_lines: args.context_lines.unwrap_or(0),
case_sensitive: args.case_sensitive.unwrap_or(true),
max_results: clamp_max_results(
args.max_results,
DEFAULT_SEARCH_MAX_RESULTS,
MAX_SEARCH_RESULTS,
),
})
.search(request)
.await
.map_err(backend_error_to_mcp)?
)
@@ -222,7 +215,7 @@ fn search_tool() -> Tool {
let mut tool = Tool::new(
Cow::Borrowed(SEARCH_TOOL_NAME),
Cow::Borrowed(
"Search Codex memory files for exact text matches, with pagination, optional surrounding context lines, and optional case-insensitive matching.",
"Search Codex memory files for line-based substring matches, optionally requiring any or all query substrings on the same line.",
),
Arc::new(schema::search_input_schema()),
);
@@ -235,6 +228,36 @@ fn parse_args<T: for<'de> Deserialize<'de>>(value: serde_json::Value) -> Result<
serde_json::from_value(value).map_err(|err| McpError::invalid_params(err.to_string(), None))
}
impl SearchArgs {
fn into_request(self) -> Result<SearchMemoriesRequest, McpError> {
let queries = match (self.query, self.queries) {
(Some(query), None) => Ok(vec![query]),
(None, Some(queries)) => Ok(queries),
(Some(_), Some(_)) => Err(McpError::invalid_params(
"provide either 'query' or 'queries', but not both".to_string(),
None,
)),
(None, None) => Err(McpError::invalid_params(
"missing required field: 'query' or 'queries'".to_string(),
None,
)),
}?;
Ok(SearchMemoriesRequest {
queries,
match_mode: self.match_mode.unwrap_or(SearchMatchMode::Any),
path: self.path,
cursor: self.cursor,
context_lines: self.context_lines.unwrap_or(0),
case_sensitive: self.case_sensitive.unwrap_or(true),
max_results: clamp_max_results(
self.max_results,
DEFAULT_SEARCH_MAX_RESULTS,
MAX_SEARCH_RESULTS,
),
})
}
}
fn clamp_max_results(requested: Option<usize>, default: usize, max: usize) -> usize {
requested.unwrap_or(default).clamp(1, max)
}
@@ -251,3 +274,73 @@ fn backend_error_to_mcp(err: MemoriesBackendError) -> McpError {
MemoriesBackendError::Io(_) => McpError::internal_error(err.to_string(), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn search_args_accept_legacy_single_query() {
let args: SearchArgs = parse_args(json!({
"query": "needle",
"match_mode": "all"
}))
.expect("legacy query args should parse");
let request = args.into_request().expect("query should convert");
assert_eq!(
request,
SearchMemoriesRequest {
queries: vec!["needle".to_string()],
match_mode: SearchMatchMode::All,
path: None,
cursor: None,
context_lines: 0,
case_sensitive: true,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
}
);
}
#[test]
fn search_args_accept_multiple_queries() {
let args: SearchArgs = parse_args(json!({
"queries": ["alpha", "needle"],
"case_sensitive": false
}))
.expect("multi-query args should parse");
let request = args.into_request().expect("queries should convert");
assert_eq!(
request,
SearchMemoriesRequest {
queries: vec!["alpha".to_string(), "needle".to_string()],
match_mode: SearchMatchMode::Any,
path: None,
cursor: None,
context_lines: 0,
case_sensitive: false,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
}
);
}
#[test]
fn search_args_reject_both_query_forms() {
let args: SearchArgs = parse_args(json!({
"query": "needle",
"queries": ["needle"]
}))
.expect("args should parse before conversion");
let err = args
.into_request()
.expect_err("query and queries should be mutually exclusive");
assert!(err.message.contains("either 'query' or 'queries'"));
}
}