mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
5512b23c95
commit
8ba294ea13
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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'"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user