crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex โ€” tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_memory_list` MCP tool handler.
//!
//! Returns paginated active memories, optionally filtered by domain tag.
//! Mirrors the query logic in `cortex memory list`
//! (`crates/cortex-cli/src/cmd/memory.rs` `list` fn) using the same
//! [`MemoryRepo`] surface.
//!
//! Does NOT return memories whose `status = 'pending_mcp_commit'` (ADR 0047 ยง1
//! filter). Only `status = 'active'` rows are returned.
//!
//! Gate: [`GateId::FtsRead`].

use std::sync::{Arc, Mutex};

use cortex_store::repo::MemoryRepo;
use serde_json::{json, Value};

use crate::{GateId, ToolError, ToolHandler};

/// Default page size for `cortex_memory_list`.
const DEFAULT_LIMIT: usize = 20;
/// Server-side maximum page size cap.
const MAX_LIMIT: usize = 100;

/// MCP tool: `cortex_memory_list`.
///
/// Schema:
/// ```text
/// cortex_memory_list(
///   domains?: [string],
///   limit?:   int,        // default 20, cap 100
///   offset?:  int,        // default 0
/// ) โ†’ { memories: [{ id, content, domains, confidence, created_at }], total: int }
/// ```
///
/// `domains` filters by tag (AND semantics: a memory must carry every supplied
/// tag). `limit` defaults to 20 and is server-capped at 100. `offset` defaults
/// to 0. Results are ordered by `updated_at DESC, id`.
#[derive(Debug)]
pub struct CortexMemoryListTool {
    pool: Arc<Mutex<cortex_store::Pool>>,
}

impl CortexMemoryListTool {
    /// Construct the tool over a shared store connection.
    #[must_use]
    pub fn new(pool: Arc<Mutex<cortex_store::Pool>>) -> Self {
        Self { pool }
    }
}

impl ToolHandler for CortexMemoryListTool {
    fn name(&self) -> &'static str {
        "cortex_memory_list"
    }

    fn gate_set(&self) -> &'static [GateId] {
        &[GateId::FtsRead]
    }

    fn call(&self, params: Value) -> Result<Value, ToolError> {
        // Parse optional domains filter.
        let domains: Vec<String> = match params.get("domains") {
            None | Some(Value::Null) => Vec::new(),
            Some(Value::Array(arr)) => {
                let mut tags = Vec::with_capacity(arr.len());
                for (i, v) in arr.iter().enumerate() {
                    match v.as_str() {
                        Some(s) => tags.push(s.to_owned()),
                        None => {
                            return Err(ToolError::InvalidParams(format!(
                                "domains[{i}] must be a string"
                            )));
                        }
                    }
                }
                tags
            }
            Some(other) => {
                return Err(ToolError::InvalidParams(format!(
                    "domains must be an array of strings, got {other}"
                )));
            }
        };

        // Parse optional limit (default 20, cap 100).
        let limit: usize = match params.get("limit") {
            None | Some(Value::Null) => DEFAULT_LIMIT,
            Some(v) => {
                let n = v.as_u64().ok_or_else(|| {
                    ToolError::InvalidParams("limit must be a non-negative integer".into())
                })?;
                let n = usize::try_from(n).unwrap_or(MAX_LIMIT);
                n.min(MAX_LIMIT)
            }
        };

        // Parse optional offset (default 0).
        let offset: usize = match params.get("offset") {
            None | Some(Value::Null) => 0,
            Some(v) => {
                let n = v.as_u64().ok_or_else(|| {
                    ToolError::InvalidParams("offset must be a non-negative integer".into())
                })?;
                usize::try_from(n).unwrap_or(0)
            }
        };

        let pool = self
            .pool
            .lock()
            .map_err(|err| ToolError::Internal(format!("failed to acquire store lock: {err}")))?;

        let repo = MemoryRepo::new(&pool);

        // Fetch active memories, applying domain filter when supplied.
        let all_memories = if domains.is_empty() {
            repo.list_by_status("active").map_err(|err| {
                ToolError::Internal(format!("failed to read active memories: {err}"))
            })?
        } else {
            repo.list_by_status_with_tags("active", &domains)
                .map_err(|err| {
                    ToolError::Internal(format!(
                        "failed to read tag-filtered active memories: {err}"
                    ))
                })?
        };

        let total = all_memories.len();

        // Apply pagination in-process (the repo returns ordered rows).
        let page: Vec<Value> = all_memories
            .into_iter()
            .skip(offset)
            .take(limit)
            .map(|m| {
                let domains_list = string_array(&m.domains_json);
                json!({
                    "id": m.id.to_string(),
                    "content": m.claim,
                    "domains": domains_list,
                    "confidence": m.confidence,
                    "created_at": m.created_at.to_rfc3339(),
                })
            })
            .collect();

        Ok(json!({
            "memories": page,
            "total": total,
        }))
    }
}

/// Extract a `Vec<String>` from a JSON array value, ignoring non-string elements.
fn string_array(value: &Value) -> Vec<String> {
    value
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|v| v.as_str().map(ToOwned::to_owned))
        .collect()
}