Skip to main content

cortex_mcp/tools/
memory_list.rs

1//! `cortex_memory_list` MCP tool handler.
2//!
3//! Returns paginated active memories, optionally filtered by domain tag.
4//! Mirrors the query logic in `cortex memory list`
5//! (`crates/cortex-cli/src/cmd/memory.rs` `list` fn) using the same
6//! [`MemoryRepo`] surface.
7//!
8//! Does NOT return memories whose `status = 'pending_mcp_commit'` (ADR 0047 §1
9//! filter). Only `status = 'active'` rows are returned.
10//!
11//! Gate: [`GateId::FtsRead`].
12
13use std::sync::{Arc, Mutex};
14
15use cortex_store::repo::MemoryRepo;
16use serde_json::{json, Value};
17
18use crate::{GateId, ToolError, ToolHandler};
19
20/// Default page size for `cortex_memory_list`.
21const DEFAULT_LIMIT: usize = 20;
22/// Server-side maximum page size cap.
23const MAX_LIMIT: usize = 100;
24
25/// MCP tool: `cortex_memory_list`.
26///
27/// Schema:
28/// ```text
29/// cortex_memory_list(
30///   domains?: [string],
31///   limit?:   int,        // default 20, cap 100
32///   offset?:  int,        // default 0
33/// ) → { memories: [{ id, content, domains, confidence, created_at }], total: int }
34/// ```
35///
36/// `domains` filters by tag (AND semantics: a memory must carry every supplied
37/// tag). `limit` defaults to 20 and is server-capped at 100. `offset` defaults
38/// to 0. Results are ordered by `updated_at DESC, id`.
39#[derive(Debug)]
40pub struct CortexMemoryListTool {
41    pool: Arc<Mutex<cortex_store::Pool>>,
42}
43
44impl CortexMemoryListTool {
45    /// Construct the tool over a shared store connection.
46    #[must_use]
47    pub fn new(pool: Arc<Mutex<cortex_store::Pool>>) -> Self {
48        Self { pool }
49    }
50}
51
52impl ToolHandler for CortexMemoryListTool {
53    fn name(&self) -> &'static str {
54        "cortex_memory_list"
55    }
56
57    fn gate_set(&self) -> &'static [GateId] {
58        &[GateId::FtsRead]
59    }
60
61    fn call(&self, params: Value) -> Result<Value, ToolError> {
62        // Parse optional domains filter.
63        let domains: Vec<String> = match params.get("domains") {
64            None | Some(Value::Null) => Vec::new(),
65            Some(Value::Array(arr)) => {
66                let mut tags = Vec::with_capacity(arr.len());
67                for (i, v) in arr.iter().enumerate() {
68                    match v.as_str() {
69                        Some(s) => tags.push(s.to_owned()),
70                        None => {
71                            return Err(ToolError::InvalidParams(format!(
72                                "domains[{i}] must be a string"
73                            )));
74                        }
75                    }
76                }
77                tags
78            }
79            Some(other) => {
80                return Err(ToolError::InvalidParams(format!(
81                    "domains must be an array of strings, got {other}"
82                )));
83            }
84        };
85
86        // Parse optional limit (default 20, cap 100).
87        let limit: usize = match params.get("limit") {
88            None | Some(Value::Null) => DEFAULT_LIMIT,
89            Some(v) => {
90                let n = v.as_u64().ok_or_else(|| {
91                    ToolError::InvalidParams("limit must be a non-negative integer".into())
92                })?;
93                let n = usize::try_from(n).unwrap_or(MAX_LIMIT);
94                n.min(MAX_LIMIT)
95            }
96        };
97
98        // Parse optional offset (default 0).
99        let offset: usize = match params.get("offset") {
100            None | Some(Value::Null) => 0,
101            Some(v) => {
102                let n = v.as_u64().ok_or_else(|| {
103                    ToolError::InvalidParams("offset must be a non-negative integer".into())
104                })?;
105                usize::try_from(n).unwrap_or(0)
106            }
107        };
108
109        let pool = self
110            .pool
111            .lock()
112            .map_err(|err| ToolError::Internal(format!("failed to acquire store lock: {err}")))?;
113
114        let repo = MemoryRepo::new(&pool);
115
116        // Fetch active memories, applying domain filter when supplied.
117        let all_memories = if domains.is_empty() {
118            repo.list_by_status("active").map_err(|err| {
119                ToolError::Internal(format!("failed to read active memories: {err}"))
120            })?
121        } else {
122            repo.list_by_status_with_tags("active", &domains)
123                .map_err(|err| {
124                    ToolError::Internal(format!(
125                        "failed to read tag-filtered active memories: {err}"
126                    ))
127                })?
128        };
129
130        let total = all_memories.len();
131
132        // Apply pagination in-process (the repo returns ordered rows).
133        let page: Vec<Value> = all_memories
134            .into_iter()
135            .skip(offset)
136            .take(limit)
137            .map(|m| {
138                let domains_list = string_array(&m.domains_json);
139                json!({
140                    "id": m.id.to_string(),
141                    "content": m.claim,
142                    "domains": domains_list,
143                    "confidence": m.confidence,
144                    "created_at": m.created_at.to_rfc3339(),
145                })
146            })
147            .collect();
148
149        Ok(json!({
150            "memories": page,
151            "total": total,
152        }))
153    }
154}
155
156/// Extract a `Vec<String>` from a JSON array value, ignoring non-string elements.
157fn string_array(value: &Value) -> Vec<String> {
158    value
159        .as_array()
160        .into_iter()
161        .flatten()
162        .filter_map(|v| v.as_str().map(ToOwned::to_owned))
163        .collect()
164}