From 8ba294ea1335a9b8fef98c8754c12673c1d078d2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 4 May 2026 15:55:06 +0200 Subject: [PATCH] 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` --- codex-rs/memories/mcp/src/backend.rs | 17 +- codex-rs/memories/mcp/src/local.rs | 81 +++++++-- codex-rs/memories/mcp/src/local_tests.rs | 220 +++++++++++++++-------- codex-rs/memories/mcp/src/schema.rs | 61 +++++-- codex-rs/memories/mcp/src/server.rs | 121 +++++++++++-- 5 files changed, 376 insertions(+), 124 deletions(-) diff --git a/codex-rs/memories/mcp/src/backend.rs b/codex-rs/memories/mcp/src/backend.rs index 05bfd2867..6cc3bd2cf 100644 --- a/codex-rs/memories/mcp/src/backend.rs +++ b/codex-rs/memories/mcp/src/backend.rs @@ -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, + pub match_mode: SearchMatchMode, pub path: Option, pub cursor: Option, 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, + pub match_mode: SearchMatchMode, pub path: Option, pub matches: Vec, pub next_cursor: Option, 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, } #[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), diff --git a/codex-rs/memories/mcp/src/local.rs b/codex-rs/memories/mcp/src/local.rs index 9bc09fbd1..0ec51a538 100644 --- a/codex-rs/memories/mcp/src/local.rs +++ b/codex-rs/memories/mcp/src/local.rs @@ -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 { - let query = request.query.trim(); - if query.is_empty() { + let queries = request + .queries + .iter() + .map(|query| query.trim().to_string()) + .collect::>(); + 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, ) -> 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, ) -> 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::>(); - 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, + normalized_queries: Option>, + match_mode: SearchMatchMode, +} + +impl SearchMatcher { + fn new(queries: Vec, match_mode: SearchMatchMode, case_sensitive: bool) -> Self { + let normalized_queries = (!case_sensitive).then(|| { + queries + .iter() + .map(|query| query.to_lowercase()) + .collect::>() + }); + Self { + queries, + normalized_queries, + match_mode, + } + } + + fn matched_queries(&self, line: &str) -> Vec { + 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, MemoriesBackendError> { let mut dir = match tokio::fs::read_dir(dir_path).await { Ok(dir) => dir, diff --git a/codex-rs/memories/mcp/src/local_tests.rs b/codex-rs/memories/mcp/src/local_tests.rs index 152d9a1e2..46480ee6f 100644 --- a/codex-rs/memories/mcp/src/local_tests.rs +++ b/codex-rs/memories/mcp/src/local_tests.rs @@ -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 { .. } diff --git a/codex-rs/memories/mcp/src/schema.rs b/codex-rs/memories/mcp/src/schema.rs index 7cf1dbd32..5adad642a 100644 --- a/codex-rs/memories/mcp/src/schema.rs +++ b/codex-rs/memories/mcp/src/schema.rs @@ -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 })) } diff --git a/codex-rs/memories/mcp/src/server.rs b/codex-rs/memories/mcp/src/server.rs index f16919d7e..b9e6d1f76 100644 --- a/codex-rs/memories/mcp/src/server.rs +++ b/codex-rs/memories/mcp/src/server.rs @@ -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, + queries: Option>, + match_mode: Option, path: Option, cursor: Option, context_lines: Option, @@ -144,20 +147,10 @@ impl ServerHandler for MemoriesMcpServer { } 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 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 { + 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, 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'")); + } +}