diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 49ec31cca56..4b437b7eb4d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3195,23 +3195,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "codex-memories-mcp" -version = "0.0.0" -dependencies = [ - "anyhow", - "codex-utils-absolute-path", - "codex-utils-output-truncation", - "pretty_assertions", - "rmcp", - "schemars 0.8.22", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "tokio", -] - [[package]] name = "codex-memories-read" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 95c065c74bd..830e2440822 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -58,7 +58,6 @@ members = [ "login", "codex-mcp", "mcp-server", - "memories/mcp", "memories/read", "memories/write", "model-provider-info", diff --git a/codex-rs/memories/README.md b/codex-rs/memories/README.md index 524f9f98036..9195e89ada8 100644 --- a/codex-rs/memories/README.md +++ b/codex-rs/memories/README.md @@ -10,8 +10,6 @@ Runtime orchestration for Phase 1 and Phase 2 still lives in `codex-core` under - `codex-rs/memories/read` (`codex-memories-read`) owns the read path: memory developer-instruction injection, memory citation parsing, and read-usage telemetry classification. -- `codex-rs/memories/mcp` (`codex-memories-mcp`) owns the read-only memory - filesystem MCP server implementation. - `codex-rs/memories/write` (`codex-memories-write`) owns the write path: Phase 1 and Phase 2 prompt rendering, filesystem artifact helpers, workspace diff helpers, and extension resource pruning. diff --git a/codex-rs/memories/mcp/BUILD.bazel b/codex-rs/memories/mcp/BUILD.bazel deleted file mode 100644 index 99048da382a..00000000000 --- a/codex-rs/memories/mcp/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "mcp", - crate_name = "codex_memories_mcp", -) diff --git a/codex-rs/memories/mcp/Cargo.toml b/codex-rs/memories/mcp/Cargo.toml deleted file mode 100644 index 847154808cd..00000000000 --- a/codex-rs/memories/mcp/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -edition.workspace = true -license.workspace = true -name = "codex-memories-mcp" -version.workspace = true - -[lib] -name = "codex_memories_mcp" -path = "src/lib.rs" -doctest = false - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -codex-utils-absolute-path = { workspace = true } -codex-utils-output-truncation = { workspace = true } -rmcp = { workspace = true, default-features = false, features = [ - "schemars", - "server", - "transport-async-rw", -] } -schemars = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs", "io-std"] } - -[dev-dependencies] -pretty_assertions = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["fs", "macros"] } diff --git a/codex-rs/memories/mcp/src/backend.rs b/codex-rs/memories/mcp/src/backend.rs deleted file mode 100644 index 929852f1b1f..00000000000 --- a/codex-rs/memories/mcp/src/backend.rs +++ /dev/null @@ -1,164 +0,0 @@ -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use std::future::Future; - -pub const DEFAULT_LIST_MAX_RESULTS: usize = 2_000; -pub const MAX_LIST_RESULTS: usize = 2_000; -pub const DEFAULT_SEARCH_MAX_RESULTS: usize = 200; -pub const MAX_SEARCH_RESULTS: usize = 200; -pub const DEFAULT_READ_MAX_TOKENS: usize = 20_000; - -/// Storage interface behind the memories MCP tools. -/// -/// Implementations should return paths relative to the memory store and enforce -/// their own storage-specific access rules. The local implementation uses the -/// filesystem today; a later implementation can satisfy the same contract from a -/// remote backend. -pub trait MemoriesBackend: Clone + Send + Sync + 'static { - fn list( - &self, - request: ListMemoriesRequest, - ) -> impl Future> + Send; - - fn read( - &self, - request: ReadMemoryRequest, - ) -> impl Future> + Send; - - fn search( - &self, - request: SearchMemoriesRequest, - ) -> impl Future> + Send; -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListMemoriesRequest { - pub path: Option, - pub cursor: Option, - pub max_results: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct ListMemoriesResponse { - pub path: Option, - pub entries: Vec, - pub next_cursor: Option, - pub truncated: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReadMemoryRequest { - pub path: String, - pub line_offset: usize, - pub max_lines: Option, - pub max_tokens: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct ReadMemoryResponse { - pub path: String, - pub start_line_number: usize, - pub content: String, - pub truncated: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SearchMemoriesRequest { - pub queries: Vec, - pub match_mode: SearchMatchMode, - pub path: Option, - pub cursor: Option, - pub context_lines: usize, - pub case_sensitive: bool, - pub normalized: bool, - pub max_results: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct SearchMemoriesResponse { - pub queries: Vec, - pub match_mode: SearchMatchMode, - pub path: Option, - pub matches: Vec, - pub next_cursor: Option, - pub truncated: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum SearchMatchMode { - Any, - AllOnSameLine, - AllWithinLines { - #[schemars(range(min = 1))] - line_count: usize, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct MemoryEntry { - pub path: String, - pub entry_type: MemoryEntryType, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum MemoryEntryType { - File, - Directory, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct MemorySearchMatch { - pub path: String, - pub match_line_number: usize, - pub content_start_line_number: usize, - pub content: String, - pub matched_queries: Vec, -} - -#[derive(Debug, thiserror::Error)] -pub enum MemoriesBackendError { - #[error("path '{path}' {reason}")] - InvalidPath { path: String, reason: String }, - #[error("cursor '{cursor}' {reason}")] - InvalidCursor { cursor: String, reason: String }, - #[error("path '{path}' was not found")] - NotFound { path: String }, - #[error("line_offset must be a 1-indexed line number")] - InvalidLineOffset, - #[error("max_lines must be a positive integer")] - InvalidMaxLines, - #[error("line_offset exceeds file length")] - LineOffsetExceedsFileLength, - #[error("path '{path}' is not a file")] - NotFile { path: String }, - #[error("queries must not be empty or contain empty strings")] - EmptyQuery, - #[error("all_within_lines.line_count must be a positive integer")] - InvalidMatchWindow, - #[error("I/O error while reading memories: {0}")] - Io(#[from] std::io::Error), -} - -impl MemoriesBackendError { - pub fn invalid_path(path: impl Into, reason: impl Into) -> Self { - Self::InvalidPath { - path: path.into(), - reason: reason.into(), - } - } - - pub fn invalid_cursor(cursor: impl Into, reason: impl Into) -> Self { - Self::InvalidCursor { - cursor: cursor.into(), - reason: reason.into(), - } - } -} diff --git a/codex-rs/memories/mcp/src/lib.rs b/codex-rs/memories/mcp/src/lib.rs deleted file mode 100644 index a643bf6657e..00000000000 --- a/codex-rs/memories/mcp/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! MCP access to Codex memories. -//! -//! This crate only exposes tools for discovering and reading memory files. The -//! policy that tells a model when to use those tools is injected elsewhere. - -pub mod backend; -pub mod local; - -mod schema; -mod server; - -pub use local::LocalMemoriesBackend; -pub use server::MemoriesMcpServer; -pub use server::run_server; -pub use server::run_stdio_server; diff --git a/codex-rs/memories/mcp/src/local.rs b/codex-rs/memories/mcp/src/local.rs deleted file mode 100644 index 97aacee1184..00000000000 --- a/codex-rs/memories/mcp/src/local.rs +++ /dev/null @@ -1,624 +0,0 @@ -use crate::backend::DEFAULT_READ_MAX_TOKENS; -use crate::backend::ListMemoriesRequest; -use crate::backend::ListMemoriesResponse; -use crate::backend::MAX_LIST_RESULTS; -use crate::backend::MAX_SEARCH_RESULTS; -use crate::backend::MemoriesBackend; -use crate::backend::MemoriesBackendError; -use crate::backend::MemoryEntry; -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; - -#[derive(Debug, Clone)] -pub struct LocalMemoriesBackend { - root: PathBuf, -} - -impl LocalMemoriesBackend { - pub fn from_codex_home(codex_home: &AbsolutePathBuf) -> Self { - Self::from_memory_root(codex_home.join("memories").to_path_buf()) - } - - pub fn from_memory_root(root: impl Into) -> Self { - Self { root: root.into() } - } - - pub fn root(&self) -> &Path { - &self.root - } - - async fn resolve_scoped_path( - &self, - relative_path: Option<&str>, - ) -> Result { - let Some(relative_path) = relative_path else { - return Ok(self.root.clone()); - }; - let relative = Path::new(relative_path); - if relative.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) { - return Err(MemoriesBackendError::invalid_path( - relative_path, - "must stay within the memories root", - )); - } - if relative.components().any(is_hidden_component) { - return Err(MemoriesBackendError::NotFound { - path: relative_path.to_string(), - }); - } - - let components = relative.components().collect::>(); - let mut scoped_path = self.root.clone(); - for (idx, component) in components.iter().enumerate() { - scoped_path.push(component.as_os_str()); - - let Some(metadata) = Self::metadata_or_none(&scoped_path).await? else { - for remaining_component in components.iter().skip(idx + 1) { - scoped_path.push(remaining_component.as_os_str()); - } - return Ok(scoped_path); - }; - - reject_symlink(&display_relative_path(&self.root, &scoped_path), &metadata)?; - if idx + 1 < components.len() && !metadata.is_dir() { - return Err(MemoriesBackendError::invalid_path( - relative_path, - "traverses through a non-directory path component", - )); - } - } - - Ok(scoped_path) - } - - async fn metadata_or_none( - path: &Path, - ) -> Result, MemoriesBackendError> { - match tokio::fs::symlink_metadata(path).await { - Ok(metadata) => Ok(Some(metadata)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.into()), - } - } -} - -impl MemoriesBackend for LocalMemoriesBackend { - async fn list( - &self, - request: ListMemoriesRequest, - ) -> Result { - let max_results = request.max_results.min(MAX_LIST_RESULTS); - let start = self.resolve_scoped_path(request.path.as_deref()).await?; - let start_index = match request.cursor.as_deref() { - Some(cursor) => cursor.parse::().map_err(|_| { - MemoriesBackendError::invalid_cursor(cursor, "must be a non-negative integer") - })?, - None => 0, - }; - let Some(metadata) = Self::metadata_or_none(&start).await? else { - return Err(MemoriesBackendError::NotFound { - path: request.path.unwrap_or_default(), - }); - }; - reject_symlink(&display_relative_path(&self.root, &start), &metadata)?; - - let mut entries = if metadata.is_file() { - vec![MemoryEntry { - path: display_relative_path(&self.root, &start), - entry_type: MemoryEntryType::File, - }] - } else if metadata.is_dir() { - let mut entries = Vec::new(); - for path in read_sorted_dir_paths(&start).await? { - if is_hidden_path(&path) { - continue; - } - let Some(metadata) = Self::metadata_or_none(&path).await? else { - continue; - }; - if metadata.file_type().is_symlink() { - continue; - } - - let entry_type = if metadata.is_dir() { - MemoryEntryType::Directory - } else if metadata.is_file() { - MemoryEntryType::File - } else { - continue; - }; - entries.push(MemoryEntry { - path: display_relative_path(&self.root, &path), - entry_type, - }); - } - entries - } else { - Vec::new() - }; - if start_index > entries.len() { - return Err(MemoriesBackendError::invalid_cursor( - start_index.to_string(), - "exceeds result count", - )); - } - - let end_index = start_index.saturating_add(max_results).min(entries.len()); - let next_cursor = (end_index < entries.len()).then(|| end_index.to_string()); - let truncated = next_cursor.is_some(); - Ok(ListMemoriesResponse { - path: request.path, - entries: entries.drain(start_index..end_index).collect(), - next_cursor, - truncated, - }) - } - - async fn read( - &self, - request: ReadMemoryRequest, - ) -> Result { - if request.line_offset == 0 { - return Err(MemoriesBackendError::InvalidLineOffset); - } - if request.max_lines == Some(0) { - return Err(MemoriesBackendError::InvalidMaxLines); - } - - let path = self - .resolve_scoped_path(Some(request.path.as_str())) - .await?; - let Some(metadata) = Self::metadata_or_none(&path).await? else { - return Err(MemoriesBackendError::NotFound { path: request.path }); - }; - reject_symlink(&request.path, &metadata)?; - if !metadata.is_file() { - return Err(MemoriesBackendError::NotFile { path: request.path }); - } - - let original_content = tokio::fs::read_to_string(&path).await?; - let start_byte = line_start_byte_offset(&original_content, request.line_offset)?; - let end_byte = line_end_byte_offset(&original_content, start_byte, request.max_lines); - let content_from_offset = &original_content[start_byte..end_byte]; - let max_tokens = if request.max_tokens == 0 { - DEFAULT_READ_MAX_TOKENS - } else { - request.max_tokens - }; - let content = truncate_text(content_from_offset, TruncationPolicy::Tokens(max_tokens)); - let truncated = end_byte < original_content.len() || content != content_from_offset; - Ok(ReadMemoryResponse { - path: request.path, - start_line_number: request.line_offset, - content, - truncated, - }) - } - - async fn search( - &self, - request: SearchMemoriesRequest, - ) -> Result { - 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); - } - if matches!( - request.match_mode, - SearchMatchMode::AllWithinLines { line_count: 0 } - ) { - return Err(MemoriesBackendError::InvalidMatchWindow); - } - - let max_results = request.max_results.min(MAX_SEARCH_RESULTS); - let start = self.resolve_scoped_path(request.path.as_deref()).await?; - let start_index = match request.cursor.as_deref() { - Some(cursor) => cursor.parse::().map_err(|_| { - MemoriesBackendError::invalid_cursor(cursor, "must be a non-negative integer") - })?, - None => 0, - }; - let Some(metadata) = Self::metadata_or_none(&start).await? else { - return Err(MemoriesBackendError::NotFound { - path: request.path.unwrap_or_default(), - }); - }; - reject_symlink(&display_relative_path(&self.root, &start), &metadata)?; - - let matcher = SearchMatcher::new( - queries.clone(), - request.match_mode.clone(), - request.case_sensitive, - request.normalized, - )?; - let mut matches = Vec::new(); - search_entries( - &self.root, - &start, - &metadata, - &matcher, - request.context_lines, - &mut matches, - ) - .await?; - matches.sort_by(|left, right| { - left.path - .cmp(&right.path) - .then(left.match_line_number.cmp(&right.match_line_number)) - }); - if start_index > matches.len() { - return Err(MemoriesBackendError::invalid_cursor( - start_index.to_string(), - "exceeds result count", - )); - } - let end_index = start_index.saturating_add(max_results).min(matches.len()); - let next_cursor = (end_index < matches.len()).then(|| end_index.to_string()); - let truncated = next_cursor.is_some(); - Ok(SearchMemoriesResponse { - queries, - match_mode: request.match_mode, - path: request.path, - matches: matches.drain(start_index..end_index).collect(), - next_cursor, - truncated, - }) - } -} - -async fn search_entries( - root: &Path, - current: &Path, - current_metadata: &std::fs::Metadata, - matcher: &SearchMatcher, - context_lines: usize, - matches: &mut Vec, -) -> Result<(), MemoriesBackendError> { - if current_metadata.is_file() { - search_file(root, current, matcher, context_lines, matches).await?; - return Ok(()); - } - if !current_metadata.is_dir() { - return Ok(()); - } - - let mut pending = vec![current.to_path_buf()]; - while let Some(dir_path) = pending.pop() { - for path in read_sorted_dir_paths(&dir_path).await? { - if is_hidden_path(&path) { - continue; - } - let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&path).await? else { - continue; - }; - if metadata.file_type().is_symlink() { - continue; - } - if metadata.is_dir() { - pending.push(path); - } else if metadata.is_file() { - search_file(root, &path, matcher, context_lines, matches).await?; - } - } - } - - Ok(()) -} - -async fn search_file( - root: &Path, - path: &Path, - matcher: &SearchMatcher, - context_lines: usize, - matches: &mut Vec, -) -> Result<(), MemoriesBackendError> { - let content = match tokio::fs::read_to_string(path).await { - Ok(content) => content, - Err(err) if err.kind() == std::io::ErrorKind::InvalidData => return Ok(()), - Err(err) => return Err(err.into()), - }; - let lines = content.lines().collect::>(); - let line_matches = lines - .iter() - .map(|line| matcher.matched_query_flags(line)) - .collect::>(); - match &matcher.match_mode { - SearchMatchMode::Any => { - for (idx, matched_query_flags) in line_matches.iter().enumerate() { - if matched_query_flags.iter().any(|matched| *matched) { - matches.push(build_search_match( - root, - path, - &lines, - idx, - idx, - context_lines, - matcher.matched_queries(matched_query_flags), - )); - } - } - } - SearchMatchMode::AllOnSameLine => { - for (idx, matched_query_flags) in line_matches.iter().enumerate() { - if matched_query_flags.iter().all(|matched| *matched) { - matches.push(build_search_match( - root, - path, - &lines, - idx, - idx, - context_lines, - matcher.matched_queries(matched_query_flags), - )); - } - } - } - SearchMatchMode::AllWithinLines { line_count } => { - let mut windows = Vec::new(); - for start_index in 0..lines.len() { - if !line_matches[start_index].iter().any(|matched| *matched) { - continue; - } - let last_allowed_index = start_index - .saturating_add(line_count.saturating_sub(1)) - .min(lines.len().saturating_sub(1)); - let mut matched_query_flags = vec![false; matcher.queries.len()]; - for (end_index, line_match_flags) in line_matches - .iter() - .enumerate() - .take(last_allowed_index + 1) - .skip(start_index) - { - for (idx, matched) in line_match_flags.iter().enumerate() { - matched_query_flags[idx] |= matched; - } - if matched_query_flags.iter().all(|matched| *matched) { - windows.push((start_index, end_index, matched_query_flags)); - break; - } - } - } - for (idx, (start_index, end_index, matched_query_flags)) in windows.iter().enumerate() { - let strictly_contains_another_window = windows.iter().enumerate().any( - |(other_idx, (other_start_index, other_end_index, _))| { - idx != other_idx - && start_index <= other_start_index - && end_index >= other_end_index - && (start_index != other_start_index || end_index != other_end_index) - }, - ); - if strictly_contains_another_window { - continue; - } - matches.push(build_search_match( - root, - path, - &lines, - *start_index, - *end_index, - context_lines, - matcher.matched_queries(matched_query_flags), - )); - } - } - } - Ok(()) -} - -fn build_search_match( - root: &Path, - path: &Path, - lines: &[&str], - match_start_index: usize, - match_end_index: usize, - context_lines: usize, - matched_queries: Vec, -) -> MemorySearchMatch { - let content_start_index = match_start_index.saturating_sub(context_lines); - let content_end_index = match_end_index - .saturating_add(context_lines) - .saturating_add(1) - .min(lines.len()); - MemorySearchMatch { - path: display_relative_path(root, path), - match_line_number: match_start_index + 1, - content_start_line_number: content_start_index + 1, - content: lines[content_start_index..content_end_index].join("\n"), - matched_queries, - } -} - -struct SearchMatcher { - queries: Vec, - prepared_queries: Vec, - comparison: SearchComparison, - match_mode: SearchMatchMode, -} - -impl SearchMatcher { - fn new( - queries: Vec, - match_mode: SearchMatchMode, - case_sensitive: bool, - normalized: bool, - ) -> Result { - let comparison = SearchComparison::new(case_sensitive, normalized); - let prepared_queries = queries - .iter() - .map(|query| comparison.prepare(query)) - .map(Cow::into_owned) - .collect::>(); - if prepared_queries.iter().any(std::string::String::is_empty) { - return Err(MemoriesBackendError::EmptyQuery); - } - Ok(Self { - queries, - prepared_queries, - comparison, - match_mode, - }) - } - - fn matched_query_flags(&self, line: &str) -> Vec { - let line = self.comparison.prepare(line); - self.prepared_queries - .iter() - .map(|query| line.as_ref().contains(query)) - .collect() - } - - fn matched_queries(&self, matched_query_flags: &[bool]) -> Vec { - self.queries - .iter() - .zip(matched_query_flags) - .filter_map(|(query, matched)| matched.then_some(query.clone())) - .collect() - } -} - -#[derive(Clone, Copy)] -struct SearchComparison { - case_sensitive: bool, - normalized: bool, -} - -impl SearchComparison { - fn new(case_sensitive: bool, normalized: bool) -> Self { - Self { - case_sensitive, - normalized, - } - } - - fn prepare<'a>(self, value: &'a str) -> Cow<'a, str> { - if self.case_sensitive && !self.normalized { - return Cow::Borrowed(value); - } - - let value = if self.case_sensitive { - Cow::Borrowed(value) - } else { - Cow::Owned(value.to_lowercase()) - }; - if !self.normalized { - return value; - } - - Cow::Owned( - value - .chars() - .filter(|ch| ch.is_alphanumeric()) - .collect::(), - ) - } -} - -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, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err.into()), - }; - let mut paths = Vec::new(); - while let Some(entry) = dir.next_entry().await? { - paths.push(entry.path()); - } - paths.sort(); - Ok(paths) -} - -fn reject_symlink(path: &str, metadata: &std::fs::Metadata) -> Result<(), MemoriesBackendError> { - if metadata.file_type().is_symlink() { - return Err(MemoriesBackendError::invalid_path( - path, - "must not be a symlink", - )); - } - Ok(()) -} - -fn is_hidden_component(component: Component<'_>) -> bool { - matches!( - component, - Component::Normal(name) if name.to_string_lossy().starts_with('.') - ) -} - -fn is_hidden_path(path: &Path) -> bool { - path.file_name() - .is_some_and(|name| name.to_string_lossy().starts_with('.')) -} - -fn display_relative_path(root: &Path, path: &Path) -> String { - path.strip_prefix(root) - .unwrap_or(path) - .components() - .map(|component| component.as_os_str().to_string_lossy()) - .filter(|component| !component.is_empty()) - .collect::>() - .join("/") -} - -fn line_start_byte_offset( - content: &str, - line_offset: usize, -) -> Result { - if line_offset == 1 { - return Ok(0); - } - - let mut current_line = 1; - for (idx, ch) in content.char_indices() { - if ch == '\n' { - current_line += 1; - if current_line == line_offset { - return Ok(idx + 1); - } - } - } - - Err(MemoriesBackendError::LineOffsetExceedsFileLength) -} - -fn line_end_byte_offset(content: &str, start_byte: usize, max_lines: Option) -> usize { - let Some(max_lines) = max_lines else { - return content.len(); - }; - - let mut lines_seen = 1; - for (relative_idx, ch) in content[start_byte..].char_indices() { - if ch == '\n' { - if lines_seen == max_lines { - return start_byte + relative_idx + 1; - } - lines_seen += 1; - } - } - - content.len() -} - -#[cfg(test)] -#[path = "local_tests.rs"] -mod tests; diff --git a/codex-rs/memories/mcp/src/local_tests.rs b/codex-rs/memories/mcp/src/local_tests.rs deleted file mode 100644 index a2dbbc04771..00000000000 --- a/codex-rs/memories/mcp/src/local_tests.rs +++ /dev/null @@ -1,1098 +0,0 @@ -use super::*; -use crate::backend::DEFAULT_LIST_MAX_RESULTS; -use crate::backend::DEFAULT_SEARCH_MAX_RESULTS; -use pretty_assertions::assert_eq; -use tempfile::TempDir; - -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, - normalized: false, - max_results: DEFAULT_SEARCH_MAX_RESULTS, - } -} - -#[tokio::test] -async fn list_returns_shallow_memory_paths() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join("skills/example")) - .await - .expect("create skills dir"); - tokio::fs::create_dir_all(tempdir.path().join(".git")) - .await - .expect("create hidden dir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary") - .await - .expect("write memory file"); - tokio::fs::write(tempdir.path().join(".DS_Store"), "metadata") - .await - .expect("write hidden file"); - tokio::fs::write(tempdir.path().join("skills/example/SKILL.md"), "skill") - .await - .expect("write skill file"); - - let response = backend(&tempdir) - .list(ListMemoriesRequest { - path: None, - cursor: None, - max_results: DEFAULT_LIST_MAX_RESULTS, - }) - .await - .expect("list memories"); - - assert_eq!( - response.entries, - vec![ - MemoryEntry { - path: "MEMORY.md".to_string(), - entry_type: MemoryEntryType::File, - }, - MemoryEntry { - path: "skills".to_string(), - entry_type: MemoryEntryType::Directory, - }, - ] - ); - assert_eq!(response.next_cursor, None); - assert_eq!(response.truncated, false); -} - -#[tokio::test] -async fn list_supports_pagination() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join("skills")) - .await - .expect("create skills dir"); - tokio::fs::create_dir_all(tempdir.path().join("rollout_summaries")) - .await - .expect("create rollout dir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary") - .await - .expect("write memory file"); - tokio::fs::write(tempdir.path().join("memory_summary.md"), "summary") - .await - .expect("write memory summary"); - - let page1 = backend(&tempdir) - .list(ListMemoriesRequest { - path: None, - cursor: None, - max_results: 2, - }) - .await - .expect("list first page"); - assert_eq!( - page1.entries, - vec![ - MemoryEntry { - path: "MEMORY.md".to_string(), - entry_type: MemoryEntryType::File, - }, - MemoryEntry { - path: "memory_summary.md".to_string(), - entry_type: MemoryEntryType::File, - }, - ] - ); - assert_eq!(page1.next_cursor.as_deref(), Some("2")); - assert_eq!(page1.truncated, true); - - let page2 = backend(&tempdir) - .list(ListMemoriesRequest { - path: None, - cursor: page1.next_cursor, - max_results: 2, - }) - .await - .expect("list second page"); - assert_eq!( - page2.entries, - vec![ - MemoryEntry { - path: "rollout_summaries".to_string(), - entry_type: MemoryEntryType::Directory, - }, - MemoryEntry { - path: "skills".to_string(), - entry_type: MemoryEntryType::Directory, - }, - ] - ); - assert_eq!(page2.next_cursor, None); - assert_eq!(page2.truncated, false); -} - -#[tokio::test] -async fn list_preserves_lexicographic_order_for_siblings() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join("a")) - .await - .expect("create a dir"); - tokio::fs::write(tempdir.path().join("a.txt"), "a") - .await - .expect("write a.txt file"); - tokio::fs::write(tempdir.path().join("b.txt"), "b") - .await - .expect("write b file"); - - let response = backend(&tempdir) - .list(ListMemoriesRequest { - path: None, - cursor: None, - max_results: DEFAULT_LIST_MAX_RESULTS, - }) - .await - .expect("list memories"); - - assert_eq!( - response - .entries - .iter() - .map(|entry| entry.path.as_str()) - .collect::>(), - vec!["a", "a.txt", "b.txt"] - ); -} - -#[tokio::test] -async fn list_scoped_directory_is_shallow() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join("skills/example")) - .await - .expect("create nested skills dir"); - tokio::fs::write(tempdir.path().join("skills/README.md"), "readme") - .await - .expect("write skills readme"); - tokio::fs::write(tempdir.path().join("skills/example/SKILL.md"), "skill") - .await - .expect("write nested skill file"); - - let response = backend(&tempdir) - .list(ListMemoriesRequest { - path: Some("skills".to_string()), - cursor: None, - max_results: DEFAULT_LIST_MAX_RESULTS, - }) - .await - .expect("list scoped directory"); - - assert_eq!( - response.entries, - vec![ - MemoryEntry { - path: "skills/README.md".to_string(), - entry_type: MemoryEntryType::File, - }, - MemoryEntry { - path: "skills/example".to_string(), - entry_type: MemoryEntryType::Directory, - }, - ] - ); -} - -#[tokio::test] -async fn list_rejects_hidden_scoped_paths() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join(".git")) - .await - .expect("create hidden dir"); - - let err = backend(&tempdir) - .list(ListMemoriesRequest { - path: Some(".git".to_string()), - cursor: None, - max_results: DEFAULT_LIST_MAX_RESULTS, - }) - .await - .expect_err("hidden scoped paths should stay invisible"); - - assert!(matches!(err, MemoriesBackendError::NotFound { .. })); -} - -#[tokio::test] -async fn list_rejects_invalid_cursor() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary") - .await - .expect("write memory file"); - - let err = backend(&tempdir) - .list(ListMemoriesRequest { - path: None, - cursor: Some("bogus".to_string()), - max_results: DEFAULT_LIST_MAX_RESULTS, - }) - .await - .expect_err("cursor should be rejected"); - - assert!(matches!(err, MemoriesBackendError::InvalidCursor { .. })); -} - -#[tokio::test] -async fn list_rejects_cursor_past_end() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary") - .await - .expect("write memory file"); - - let err = backend(&tempdir) - .list(ListMemoriesRequest { - path: None, - cursor: Some("2".to_string()), - max_results: DEFAULT_LIST_MAX_RESULTS, - }) - .await - .expect_err("cursor past end should be rejected"); - - assert!(matches!(err, MemoriesBackendError::InvalidCursor { .. })); -} - -#[tokio::test] -async fn read_rejects_directory_and_returns_file_content() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "remember this") - .await - .expect("write memory file"); - - let response = backend(&tempdir) - .read(ReadMemoryRequest { - path: "MEMORY.md".to_string(), - line_offset: 1, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect("read memory"); - - assert_eq!( - response, - ReadMemoryResponse { - path: "MEMORY.md".to_string(), - start_line_number: 1, - content: "remember this".to_string(), - truncated: false, - } - ); - - let err = backend(&tempdir) - .read(ReadMemoryRequest { - path: ".".to_string(), - line_offset: 1, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect_err("directory should not be readable as file"); - assert!(matches!(err, MemoriesBackendError::NotFile { .. })); -} - -#[tokio::test] -async fn read_rejects_missing_paths() { - let tempdir = TempDir::new().expect("tempdir"); - - let err = backend(&tempdir) - .read(ReadMemoryRequest { - path: "missing.md".to_string(), - line_offset: 1, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect_err("missing files should be rejected"); - - assert!(matches!(err, MemoriesBackendError::NotFound { .. })); -} - -#[tokio::test] -async fn read_supports_line_offset() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "alpha\nbeta\ngamma\n") - .await - .expect("write memory file"); - - let response = backend(&tempdir) - .read(ReadMemoryRequest { - path: "MEMORY.md".to_string(), - line_offset: 2, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect("read memory from line offset"); - - assert_eq!( - response, - ReadMemoryResponse { - path: "MEMORY.md".to_string(), - start_line_number: 2, - content: "beta\ngamma\n".to_string(), - truncated: false, - } - ); -} - -#[tokio::test] -async fn read_rejects_hidden_paths() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join(".git")) - .await - .expect("create hidden dir"); - tokio::fs::write(tempdir.path().join(".git/HEAD"), "ref: refs/heads/main\n") - .await - .expect("write hidden file"); - - let err = backend(&tempdir) - .read(ReadMemoryRequest { - path: ".git/HEAD".to_string(), - line_offset: 1, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect_err("hidden paths should stay invisible"); - - assert!(matches!(err, MemoriesBackendError::NotFound { .. })); -} - -#[tokio::test] -async fn read_supports_max_lines() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "alpha\nbeta\ngamma\n") - .await - .expect("write memory file"); - - let response = backend(&tempdir) - .read(ReadMemoryRequest { - path: "MEMORY.md".to_string(), - line_offset: 2, - max_lines: Some(1), - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect("read memory with line limit"); - - assert_eq!( - response, - ReadMemoryResponse { - path: "MEMORY.md".to_string(), - start_line_number: 2, - content: "beta\n".to_string(), - truncated: true, - } - ); -} - -#[tokio::test] -async fn read_rejects_invalid_line_requests() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "only\n") - .await - .expect("write memory file"); - - let zero_offset_err = backend(&tempdir) - .read(ReadMemoryRequest { - path: "MEMORY.md".to_string(), - line_offset: 0, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect_err("zero line offset should fail"); - assert!(matches!( - zero_offset_err, - MemoriesBackendError::InvalidLineOffset - )); - - let zero_max_lines_err = backend(&tempdir) - .read(ReadMemoryRequest { - path: "MEMORY.md".to_string(), - line_offset: 1, - max_lines: Some(0), - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect_err("zero max lines should fail"); - assert!(matches!( - zero_max_lines_err, - MemoriesBackendError::InvalidMaxLines - )); - - let past_end_err = backend(&tempdir) - .read(ReadMemoryRequest { - path: "MEMORY.md".to_string(), - line_offset: 3, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect_err("line offset past end should fail"); - assert!(matches!( - past_end_err, - MemoriesBackendError::LineOffsetExceedsFileLength - )); -} - -#[tokio::test] -async fn search_supports_directory_and_file_scopes() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join("rollout_summaries")) - .await - .expect("create rollout summaries dir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "alpha\nneedle\n") - .await - .expect("write memory file"); - tokio::fs::write( - tempdir.path().join("rollout_summaries/a.jsonl"), - "needle again\n", - ) - .await - .expect("write rollout summary"); - - let response = backend(&tempdir) - .search(search_request(&["needle"])) - .await - .expect("search all memories"); - assert_eq!( - response.matches, - vec![ - 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: "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(request) - .await - .expect("search one memory file"); - assert_eq!( - file_response.matches, - vec![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()], - }] - ); - assert_eq!(file_response.next_cursor, None); - assert_eq!(file_response.truncated, false); -} - -#[tokio::test] -async fn search_supports_pagination() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join("rollout_summaries")) - .await - .expect("create rollout summaries dir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle one\nneedle two\n") - .await - .expect("write memory file"); - tokio::fs::write( - tempdir.path().join("rollout_summaries/a.jsonl"), - "needle three\n", - ) - .await - .expect("write rollout summary"); - - let mut page1_request = search_request(&["needle"]); - page1_request.max_results = 2; - let page1 = backend(&tempdir) - .search(page1_request) - .await - .expect("search first page"); - assert_eq!( - page1.matches, - vec![ - MemorySearchMatch { - path: "MEMORY.md".to_string(), - 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(page2_request) - .await - .expect("search second page"); - assert_eq!( - page2.matches, - vec![MemorySearchMatch { - path: "rollout_summaries/a.jsonl".to_string(), - 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_skips_hidden_paths() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join(".git")) - .await - .expect("create hidden dir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle visible\n") - .await - .expect("write visible file"); - tokio::fs::write(tempdir.path().join(".git/HEAD"), "needle hidden\n") - .await - .expect("write hidden file"); - tokio::fs::write(tempdir.path().join(".hidden"), "needle hidden\n") - .await - .expect("write hidden file"); - - let response = backend(&tempdir) - .search(search_request(&["needle"])) - .await - .expect("search memories"); - - assert_eq!( - response.matches, - vec![MemorySearchMatch { - path: "MEMORY.md".to_string(), - match_line_number: 1, - content_start_line_number: 1, - content: "needle visible".to_string(), - matched_queries: vec!["needle".to_string()], - }] - ); -} - -#[tokio::test] -async fn search_rejects_hidden_scoped_paths() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::create_dir_all(tempdir.path().join(".git")) - .await - .expect("create hidden dir"); - - let mut request = search_request(&["needle"]); - request.path = Some(".git".to_string()); - let err = backend(&tempdir) - .search(request) - .await - .expect_err("hidden scoped paths should stay invisible"); - - assert!(matches!(err, MemoriesBackendError::NotFound { .. })); -} - -#[tokio::test] -async fn search_supports_context_lines() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write( - tempdir.path().join("MEMORY.md"), - "alpha\nneedle\nomega\nneedle again\n", - ) - .await - .expect("write memory file"); - - let mut request = search_request(&["needle"]); - request.context_lines = 1; - let response = backend(&tempdir) - .search(request) - .await - .expect("search with context"); - - assert_eq!( - response.matches, - vec![ - MemorySearchMatch { - path: "MEMORY.md".to_string(), - 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()], - }, - ] - ); -} - -#[tokio::test] -async fn search_supports_case_insensitive_matching() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "Needle\nneedle\nNEEDLE\n") - .await - .expect("write memory file"); - - let sensitive_response = backend(&tempdir) - .search(search_request(&["needle"])) - .await - .expect("search with case-sensitive matching"); - assert_eq!( - sensitive_response.matches, - vec![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()], - }] - ); - - let mut request = search_request(&["needle"]); - request.case_sensitive = false; - let insensitive_response = backend(&tempdir) - .search(request) - .await - .expect("search with case-insensitive matching"); - assert_eq!( - insensitive_response.matches, - vec![ - MemorySearchMatch { - path: "MEMORY.md".to_string(), - 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_normalized_matching() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write( - tempdir.path().join("MEMORY.md"), - "MultiAgentV2\ncold-resume\n", - ) - .await - .expect("write memory file"); - - let literal_response = backend(&tempdir) - .search(search_request(&["multi agent v2", "cold resume"])) - .await - .expect("search without normalization"); - assert_eq!(literal_response.matches, Vec::new()); - - let mut request = search_request(&["multi agent v2", "cold resume"]); - request.case_sensitive = false; - request.normalized = true; - let normalized_response = backend(&tempdir) - .search(request) - .await - .expect("search with normalization"); - assert_eq!( - normalized_response.matches, - vec![ - MemorySearchMatch { - path: "MEMORY.md".to_string(), - match_line_number: 1, - content_start_line_number: 1, - content: "MultiAgentV2".to_string(), - matched_queries: vec!["multi agent v2".to_string()], - }, - MemorySearchMatch { - path: "MEMORY.md".to_string(), - match_line_number: 2, - content_start_line_number: 2, - content: "cold-resume".to_string(), - matched_queries: vec!["cold resume".to_string()], - }, - ] - ); -} - -#[tokio::test] -async fn search_rejects_queries_that_normalize_to_empty_strings() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle\n") - .await - .expect("write memory file"); - - let mut request = search_request(&["-"]); - request.normalized = true; - let err = backend(&tempdir) - .search(request) - .await - .expect_err("separator-only normalized queries should be rejected"); - - assert!(matches!(err, MemoriesBackendError::EmptyQuery)); -} - -#[tokio::test] -async fn search_supports_any_and_all_on_same_line_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::AllOnSameLine; - let all_response = backend(&tempdir) - .search(request) - .await - .expect("search with all-on-same-line 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_supports_all_within_lines_match_mode() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write( - tempdir.path().join("MEMORY.md"), - "alpha first\nmiddle\nneedle later\nalpha again needle together\n", - ) - .await - .expect("write memory file"); - - let mut request = search_request(&["alpha", "needle"]); - request.match_mode = SearchMatchMode::AllWithinLines { line_count: 3 }; - request.context_lines = 1; - let response = backend(&tempdir) - .search(request) - .await - .expect("search with all-within-lines match mode"); - - assert_eq!( - response.matches, - vec![ - MemorySearchMatch { - path: "MEMORY.md".to_string(), - match_line_number: 1, - content_start_line_number: 1, - content: "alpha first\nmiddle\nneedle later\nalpha again needle together" - .to_string(), - matched_queries: vec!["alpha".to_string(), "needle".to_string()], - }, - MemorySearchMatch { - path: "MEMORY.md".to_string(), - match_line_number: 4, - content_start_line_number: 3, - content: "needle later\nalpha again needle together".to_string(), - matched_queries: vec!["alpha".to_string(), "needle".to_string()], - }, - ] - ); -} - -#[tokio::test] -async fn search_rejects_zero_line_window() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle\n") - .await - .expect("write memory file"); - - let mut request = search_request(&["needle"]); - request.match_mode = SearchMatchMode::AllWithinLines { line_count: 0 }; - let err = backend(&tempdir) - .search(request) - .await - .expect_err("zero-width windows should be rejected"); - - assert!(matches!(err, MemoriesBackendError::InvalidMatchWindow)); -} - -#[tokio::test] -async fn search_rejects_invalid_cursor() { - let tempdir = TempDir::new().expect("tempdir"); - tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle\n") - .await - .expect("write memory file"); - - let mut request = search_request(&["needle"]); - request.cursor = Some("bogus".to_string()); - let err = backend(&tempdir) - .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(request) - .await - .expect_err("cursor past end should be rejected"); - assert!(matches!( - past_end_err, - MemoriesBackendError::InvalidCursor { .. } - )); -} - -#[tokio::test] -async fn list_rejects_missing_scoped_paths() { - let tempdir = TempDir::new().expect("tempdir"); - - let err = backend(&tempdir) - .list(ListMemoriesRequest { - path: Some("missing".to_string()), - cursor: None, - max_results: DEFAULT_LIST_MAX_RESULTS, - }) - .await - .expect_err("missing scoped paths should be rejected"); - - assert!(matches!(err, MemoriesBackendError::NotFound { .. })); -} - -#[tokio::test] -async fn search_rejects_missing_scoped_paths() { - let tempdir = TempDir::new().expect("tempdir"); - - let mut request = search_request(&["needle"]); - request.path = Some("missing".to_string()); - let err = backend(&tempdir) - .search(request) - .await - .expect_err("missing scoped paths should be rejected"); - - assert!(matches!(err, MemoriesBackendError::NotFound { .. })); -} - -#[tokio::test] -async fn scoped_paths_reject_parent_segments() { - let tempdir = TempDir::new().expect("tempdir"); - let err = backend(&tempdir) - .read(ReadMemoryRequest { - path: "../secret".to_string(), - line_offset: 1, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect_err("parent traversal should fail"); - - assert!(matches!(err, MemoriesBackendError::InvalidPath { .. })); -} - -#[cfg(unix)] -#[tokio::test] -async fn read_rejects_symlinked_files() { - let tempdir = TempDir::new().expect("tempdir"); - let outside = tempdir.path().join("outside.txt"); - tokio::fs::write(&outside, "outside") - .await - .expect("write outside file"); - std::os::unix::fs::symlink(&outside, tempdir.path().join("inside-link")) - .expect("create symlink"); - - let err = backend(&tempdir) - .read(ReadMemoryRequest { - path: "inside-link".to_string(), - line_offset: 1, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect_err("symlink should be rejected"); - - assert!(matches!(err, MemoriesBackendError::InvalidPath { .. })); -} - -#[cfg(unix)] -#[tokio::test] -async fn read_rejects_symlinked_ancestor_directories() { - let tempdir = TempDir::new().expect("tempdir"); - let outside = tempdir.path().join("outside"); - tokio::fs::create_dir_all(&outside) - .await - .expect("create outside dir"); - tokio::fs::write(outside.join("secret.md"), "outside secret") - .await - .expect("write outside file"); - std::os::unix::fs::symlink(&outside, tempdir.path().join("skills")).expect("create symlink"); - - let err = backend(&tempdir) - .read(ReadMemoryRequest { - path: "skills/secret.md".to_string(), - line_offset: 1, - max_lines: None, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .expect_err("symlinked ancestors should be rejected"); - - assert!(matches!(err, MemoriesBackendError::InvalidPath { .. })); -} - -#[cfg(unix)] -#[tokio::test] -async fn list_rejects_symlinked_directories() { - let tempdir = TempDir::new().expect("tempdir"); - let outside = tempdir.path().join("outside"); - tokio::fs::create_dir_all(&outside) - .await - .expect("create outside dir"); - std::os::unix::fs::symlink(&outside, tempdir.path().join("skills")).expect("create symlink"); - - let err = backend(&tempdir) - .list(ListMemoriesRequest { - path: Some("skills".to_string()), - cursor: None, - max_results: DEFAULT_LIST_MAX_RESULTS, - }) - .await - .expect_err("symlinked directories should be rejected"); - - assert!(matches!(err, MemoriesBackendError::InvalidPath { .. })); -} - -#[cfg(unix)] -#[tokio::test] -async fn search_rejects_symlinked_directories() { - let tempdir = TempDir::new().expect("tempdir"); - let outside = tempdir.path().join("outside"); - tokio::fs::create_dir_all(&outside) - .await - .expect("create outside dir"); - tokio::fs::write(outside.join("secret.md"), "needle") - .await - .expect("write outside file"); - std::os::unix::fs::symlink(&outside, tempdir.path().join("skills")).expect("create symlink"); - - let mut request = search_request(&["needle"]); - request.path = Some("skills".to_string()); - let err = backend(&tempdir) - .search(request) - .await - .expect_err("symlinked directories should be rejected"); - - assert!(matches!(err, MemoriesBackendError::InvalidPath { .. })); -} diff --git a/codex-rs/memories/mcp/src/schema.rs b/codex-rs/memories/mcp/src/schema.rs deleted file mode 100644 index 2f01d2c95b5..00000000000 --- a/codex-rs/memories/mcp/src/schema.rs +++ /dev/null @@ -1,42 +0,0 @@ -use rmcp::model::JsonObject; -use schemars::JsonSchema; -use schemars::r#gen::SchemaSettings; - -pub(crate) fn input_schema_for() -> JsonObject { - schema_for::(/*option_add_null_type*/ false) -} - -pub(crate) fn output_schema_for() -> JsonObject { - schema_for::(/*option_add_null_type*/ true) -} - -fn schema_for(option_add_null_type: bool) -> JsonObject { - let schema = SchemaSettings::draft2019_09() - .with(|settings| { - settings.inline_subschemas = true; - settings.option_add_null_type = option_add_null_type; - }) - .into_generator() - .into_root_schema_for::(); - let schema_value = serde_json::to_value(schema) - .unwrap_or_else(|err| panic!("generated tool schema should serialize: {err}")); - let serde_json::Value::Object(mut schema_object) = schema_value else { - unreachable!("root tool schema must be an object"); - }; - - // MCP tools only need the JSON Schema body, not schemars' root metadata. - let mut tool_schema = JsonObject::new(); - for key in [ - "properties", - "required", - "type", - "additionalProperties", - "$defs", - "definitions", - ] { - if let Some(value) = schema_object.remove(key) { - tool_schema.insert(key.to_string(), value); - } - } - tool_schema -} diff --git a/codex-rs/memories/mcp/src/server.rs b/codex-rs/memories/mcp/src/server.rs deleted file mode 100644 index 74972699379..00000000000 --- a/codex-rs/memories/mcp/src/server.rs +++ /dev/null @@ -1,401 +0,0 @@ -use crate::backend::DEFAULT_LIST_MAX_RESULTS; -use crate::backend::DEFAULT_READ_MAX_TOKENS; -use crate::backend::DEFAULT_SEARCH_MAX_RESULTS; -use crate::backend::ListMemoriesRequest; -use crate::backend::ListMemoriesResponse; -use crate::backend::MAX_LIST_RESULTS; -use crate::backend::MAX_SEARCH_RESULTS; -use crate::backend::MemoriesBackend; -use crate::backend::MemoriesBackendError; -use crate::backend::ReadMemoryRequest; -use crate::backend::ReadMemoryResponse; -use crate::backend::SearchMatchMode; -use crate::backend::SearchMemoriesRequest; -use crate::backend::SearchMemoriesResponse; -use crate::local::LocalMemoriesBackend; -use crate::schema; -use anyhow::Context; -use codex_utils_absolute_path::AbsolutePathBuf; -use rmcp::ErrorData as McpError; -use rmcp::ServiceExt; -use rmcp::handler::server::ServerHandler; -use rmcp::model::CallToolRequestParams; -use rmcp::model::CallToolResult; -use rmcp::model::Content; -use rmcp::model::ListToolsResult; -use rmcp::model::PaginatedRequestParams; -use rmcp::model::ServerCapabilities; -use rmcp::model::ServerInfo; -use rmcp::model::Tool; -use rmcp::model::ToolAnnotations; -use schemars::JsonSchema; -use serde::Deserialize; -use serde_json::json; -use std::borrow::Cow; -use std::sync::Arc; - -const LIST_TOOL_NAME: &str = "list"; -const READ_TOOL_NAME: &str = "read"; -const SEARCH_TOOL_NAME: &str = "search"; - -#[derive(Clone)] -pub struct MemoriesMcpServer { - backend: B, - tools: Arc>, -} - -#[derive(Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct ListArgs { - path: Option, - cursor: Option, - #[schemars(range(min = 1))] - max_results: Option, -} - -#[derive(Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct ReadArgs { - path: String, - #[schemars(range(min = 1))] - line_offset: Option, - #[schemars(range(min = 1))] - max_lines: Option, -} - -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct SearchArgs { - #[schemars(length(min = 1))] - queries: Vec, - match_mode: Option, - path: Option, - cursor: Option, - #[schemars(range(min = 0))] - context_lines: Option, - case_sensitive: Option, - normalized: Option, - #[schemars(range(min = 1))] - max_results: Option, -} - -impl MemoriesMcpServer { - pub fn new(backend: B) -> Self { - Self { - backend, - tools: Arc::new(vec![list_tool(), read_tool(), search_tool()]), - } - } -} - -impl ServerHandler for MemoriesMcpServer { - fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some( - "Use these tools to list, read, and search Codex memory files.".to_string(), - ), - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } - } - - fn list_tools( - &self, - _request: Option, - _context: rmcp::service::RequestContext, - ) -> impl std::future::Future> + Send + '_ { - let tools = Arc::clone(&self.tools); - async move { - Ok(ListToolsResult { - tools: (*tools).clone(), - next_cursor: None, - meta: None, - }) - } - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - _context: rmcp::service::RequestContext, - ) -> Result { - let value = serde_json::Value::Object( - request - .arguments - .unwrap_or_default() - .into_iter() - .collect::>(), - ); - let structured_content = match request.name.as_ref() { - LIST_TOOL_NAME => { - let args: ListArgs = parse_args(value)?; - json!( - self.backend - .list(ListMemoriesRequest { - path: args.path, - cursor: args.cursor, - max_results: clamp_max_results( - args.max_results, - DEFAULT_LIST_MAX_RESULTS, - MAX_LIST_RESULTS, - ), - }) - .await - .map_err(backend_error_to_mcp)? - ) - } - READ_TOOL_NAME => { - let args: ReadArgs = parse_args(value)?; - json!( - self.backend - .read(ReadMemoryRequest { - path: args.path, - line_offset: args.line_offset.unwrap_or(1), - max_lines: args.max_lines, - max_tokens: DEFAULT_READ_MAX_TOKENS, - }) - .await - .map_err(backend_error_to_mcp)? - ) - } - SEARCH_TOOL_NAME => { - let args: SearchArgs = parse_args(value)?; - let request = args.into_request(); - json!( - self.backend - .search(request) - .await - .map_err(backend_error_to_mcp)? - ) - } - other => { - return Err(McpError::invalid_params( - format!("unknown tool: {other}"), - None, - )); - } - }; - - Ok(CallToolResult { - content: vec![Content::text(structured_content.to_string())], - structured_content: Some(structured_content), - is_error: Some(false), - meta: None, - }) - } -} - -pub async fn run_server(codex_home: &AbsolutePathBuf, transport: T) -> anyhow::Result<()> -where - T: rmcp::transport::IntoTransport, - E: std::error::Error + Send + Sync + 'static, -{ - let backend = LocalMemoriesBackend::from_codex_home(codex_home); - tokio::fs::create_dir_all(backend.root()) - .await - .with_context(|| format!("create memories root at {}", backend.root().display()))?; - MemoriesMcpServer::new(backend) - .serve(transport) - .await? - .waiting() - .await?; - Ok(()) -} - -pub async fn run_stdio_server(codex_home: &AbsolutePathBuf) -> anyhow::Result<()> { - run_server(codex_home, (tokio::io::stdin(), tokio::io::stdout())).await -} - -fn list_tool() -> Tool { - let mut tool = Tool::new( - Cow::Borrowed(LIST_TOOL_NAME), - Cow::Borrowed( - "List immediate files and directories under a path in the Codex memories store.", - ), - Arc::new(schema::input_schema_for::()), - ); - tool.output_schema = Some(Arc::new(schema::output_schema_for::())); - tool.annotations = Some(ToolAnnotations::new().read_only(true)); - tool -} - -fn read_tool() -> Tool { - let mut tool = Tool::new( - Cow::Borrowed(READ_TOOL_NAME), - Cow::Borrowed( - "Read a Codex memory file by relative path, optionally starting at a 1-indexed line offset and limiting the number of lines returned.", - ), - Arc::new(schema::input_schema_for::()), - ); - tool.output_schema = Some(Arc::new(schema::output_schema_for::())); - tool.annotations = Some(ToolAnnotations::new().read_only(true)); - tool -} - -fn search_tool() -> Tool { - let mut tool = Tool::new( - Cow::Borrowed(SEARCH_TOOL_NAME), - Cow::Borrowed( - "Search Codex memory files for substring matches, optionally normalizing separators or requiring all query substrings on the same line or within a line window.", - ), - Arc::new(schema::input_schema_for::()), - ); - tool.output_schema = Some(Arc::new( - schema::output_schema_for::(), - )); - tool.annotations = Some(ToolAnnotations::new().read_only(true)); - tool -} - -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) -> SearchMemoriesRequest { - SearchMemoriesRequest { - queries: self.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), - normalized: self.normalized.unwrap_or(false), - 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) -} - -fn backend_error_to_mcp(err: MemoriesBackendError) -> McpError { - match err { - MemoriesBackendError::InvalidPath { .. } - | MemoriesBackendError::InvalidCursor { .. } - | MemoriesBackendError::NotFound { .. } - | MemoriesBackendError::InvalidLineOffset - | MemoriesBackendError::InvalidMaxLines - | MemoriesBackendError::LineOffsetExceedsFileLength - | MemoriesBackendError::NotFile { .. } - | MemoriesBackendError::EmptyQuery - | MemoriesBackendError::InvalidMatchWindow => { - McpError::invalid_params(err.to_string(), None) - } - 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_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(); - - 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, - normalized: false, - max_results: DEFAULT_SEARCH_MAX_RESULTS, - } - ); - } - - #[test] - fn search_args_accept_windowed_all_match_mode() { - let args: SearchArgs = parse_args(json!({ - "queries": ["alpha", "needle"], - "match_mode": { - "type": "all_within_lines", - "line_count": 3 - } - })) - .expect("windowed all args should parse"); - - let request = args.into_request(); - - assert_eq!( - request, - SearchMemoriesRequest { - queries: vec!["alpha".to_string(), "needle".to_string()], - match_mode: SearchMatchMode::AllWithinLines { line_count: 3 }, - path: None, - cursor: None, - context_lines: 0, - case_sensitive: true, - normalized: false, - max_results: DEFAULT_SEARCH_MAX_RESULTS, - } - ); - } - - #[test] - fn search_args_accept_normalized_matching() { - let args: SearchArgs = parse_args(json!({ - "queries": ["multi agent v2"], - "case_sensitive": false, - "normalized": true - })) - .expect("normalized args should parse"); - - let request = args.into_request(); - - assert_eq!( - request, - SearchMemoriesRequest { - queries: vec!["multi agent v2".to_string()], - match_mode: SearchMatchMode::Any, - path: None, - cursor: None, - context_lines: 0, - case_sensitive: false, - normalized: true, - max_results: DEFAULT_SEARCH_MAX_RESULTS, - } - ); - } - - #[test] - fn search_args_reject_legacy_single_query() { - let err = parse_args::(json!({ - "query": "needle", - })) - .expect_err("legacy query field should be rejected"); - - assert!(err.message.contains("unknown field")); - assert!(err.message.contains("query")); - } - - #[test] - fn search_args_reject_unknown_fields() { - let err = parse_args::(json!({ - "queries": ["needle"], - "query": "needle" - })) - .expect_err("unknown fields should be rejected"); - - assert!(err.message.contains("unknown field")); - assert!(err.message.contains("query")); - } -}