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_note` MCP tool handler.
//!
//! Directly admits an operator-written fact as an active memory, bypassing
//! the reflection pipeline. The claim goes straight into the active memory
//! store without a candidate stage or an LLM — the operator's act of writing
//! the note is itself the provenance.
//!
//! Gate: [`GateId::SessionWrite`].
//! Tier: supervised — executes and logs on every call (no confirmation token
//! required).

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

use chrono::Utc;
use cortex_core::MemoryId;
use cortex_store::repo::memories::MemoryCandidate;
use cortex_store::repo::MemoryRepo;
use cortex_store::Pool;
use serde_json::{json, Value};

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

/// MCP tool: `cortex_memory_note`.
///
/// Schema:
/// ```text
/// cortex_memory_note(
///   claim:       string,    // required, non-empty
///   domains?:    [string],  // default ["operator_note"]
///   confidence?: float,     // default 0.9, must be in [0.0, 1.0]
/// ) -> {
///   memory_id:  string,
///   claim:      string,
///   domains:    [string],
///   confidence: float,
///   status:     "active",
/// }
/// ```
///
/// When `claim` is empty the call returns `ToolError::InvalidParams`.
/// When `confidence` is outside `[0.0, 1.0]` the call returns
/// `ToolError::InvalidParams`.
#[derive(Debug)]
pub struct CortexMemoryNoteTool {
    pool: Arc<Mutex<Pool>>,
}

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

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

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

    fn call(&self, params: Value) -> Result<Value, ToolError> {
        // ── claim (required, non-empty) ─────────────────────────────────────
        let claim = params["claim"]
            .as_str()
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or_else(|| ToolError::InvalidParams("claim is required and must not be empty".into()))?
            .to_string();

        // ── confidence (optional, default 0.9, range [0.0, 1.0]) ────────────
        let confidence = if params["confidence"].is_null() || params.get("confidence").is_none() {
            0.9_f64
        } else {
            params["confidence"]
                .as_f64()
                .ok_or_else(|| ToolError::InvalidParams("confidence must be a number".into()))?
        };

        if !(0.0..=1.0).contains(&confidence) {
            return Err(ToolError::InvalidParams(
                "confidence must be in [0.0, 1.0]".into(),
            ));
        }

        // ── domains (optional, default ["operator_note"]) ────────────────────
        let domains: Vec<String> = if let Some(arr) = params["domains"].as_array() {
            if arr.is_empty() {
                vec!["operator_note".to_string()]
            } else {
                arr.iter()
                    .filter_map(|v| v.as_str().map(str::to_string))
                    .collect()
            }
        } else {
            vec!["operator_note".to_string()]
        };

        tracing::info!(
            claim = %claim,
            confidence = confidence,
            domains = ?domains,
            "cortex_memory_note via MCP"
        );

        let pool = self
            .pool
            .lock()
            .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
        let repo = MemoryRepo::new(&pool);
        let now = Utc::now();
        let id = MemoryId::new();

        let candidate = MemoryCandidate {
            id,
            memory_type: "operator_note".to_string(),
            claim: claim.clone(),
            confidence,
            authority: "operator".to_string(),
            domains_json: serde_json::to_value(&domains)
                .unwrap_or_else(|_| serde_json::json!([])),
            source_events_json: json!([]),
            source_episodes_json: json!([]),
            salience_json: json!({}),
            applies_when_json: json!(null),
            does_not_apply_when_json: json!(null),
            created_at: now,
            updated_at: now,
        };

        repo.insert_candidate(&candidate).map_err(|err| {
            ToolError::Internal(format!("failed to insert operator note: {err}"))
        })?;

        // Promote directly to active — operator note is self-attesting.
        repo.set_active(&id, now).map_err(|err| {
            ToolError::Internal(format!("failed to activate operator note {id}: {err}"))
        })?;

        let memory_id = id.to_string();

        Ok(json!({
            "memory_id":  memory_id,
            "claim":      claim,
            "domains":    domains,
            "confidence": confidence,
            "status":     "active",
        }))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn make_tool() -> CortexMemoryNoteTool {
        let pool = rusqlite::Connection::open_in_memory().expect("in-memory sqlite");
        cortex_store::migrate::apply_pending(&pool).expect("migrations");
        CortexMemoryNoteTool::new(Arc::new(Mutex::new(pool)))
    }

    #[test]
    fn name_and_gate() {
        let tool = make_tool();
        assert_eq!(tool.name(), "cortex_memory_note");
        assert_eq!(tool.gate_set(), &[GateId::SessionWrite]);
    }

    #[test]
    fn empty_claim_returns_invalid_params() {
        let tool = make_tool();
        let err = tool.call(json!({ "claim": "" })).unwrap_err();
        assert!(matches!(err, ToolError::InvalidParams(_)));
    }

    #[test]
    fn blank_claim_returns_invalid_params() {
        let tool = make_tool();
        let err = tool.call(json!({ "claim": "   " })).unwrap_err();
        assert!(matches!(err, ToolError::InvalidParams(_)));
    }

    #[test]
    fn missing_claim_returns_invalid_params() {
        let tool = make_tool();
        let err = tool.call(json!({ "confidence": 0.8 })).unwrap_err();
        assert!(matches!(err, ToolError::InvalidParams(_)));
    }

    #[test]
    fn out_of_range_confidence_returns_invalid_params() {
        let tool = make_tool();
        let err = tool
            .call(json!({ "claim": "fact", "confidence": 1.5 }))
            .unwrap_err();
        assert!(matches!(err, ToolError::InvalidParams(_)));

        let err2 = tool
            .call(json!({ "claim": "fact", "confidence": -0.1 }))
            .unwrap_err();
        assert!(matches!(err2, ToolError::InvalidParams(_)));
    }

    #[test]
    fn valid_claim_creates_active_memory() {
        let tool = make_tool();
        let result = tool
            .call(json!({
                "claim":      "Cortex uses BLAKE3 for embedding stubs",
                "domains":    ["cortex", "embeddings"],
                "confidence": 0.95,
            }))
            .unwrap();

        assert_eq!(result["status"], "active");
        assert_eq!(result["claim"], "Cortex uses BLAKE3 for embedding stubs");
        assert_eq!(result["confidence"], 0.95);
        let domains = result["domains"].as_array().unwrap();
        assert!(domains.iter().any(|d| d == "cortex"));
        assert!(domains.iter().any(|d| d == "embeddings"));
        // memory_id must be a non-empty string
        assert!(!result["memory_id"].as_str().unwrap_or("").is_empty());
    }

    #[test]
    fn default_domain_when_none_provided() {
        let tool = make_tool();
        let result = tool
            .call(json!({ "claim": "A bare fact with no domain" }))
            .unwrap();
        let domains = result["domains"].as_array().unwrap();
        assert_eq!(domains.len(), 1);
        assert_eq!(domains[0], "operator_note");
    }

    #[test]
    fn default_confidence_when_none_provided() {
        let tool = make_tool();
        let result = tool
            .call(json!({ "claim": "A bare fact" }))
            .unwrap();
        assert!((result["confidence"].as_f64().unwrap() - 0.9).abs() < f64::EPSILON);
    }
}