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_outcome` MCP tool handler.
//!
//! Records a helpful / not-helpful outcome verdict for an active memory used
//! in a session. Mirrors the `cortex memory outcome record` CLI path
//! (`crates/cortex-cli/src/cmd/memory.rs` `outcome_record` fn) using the
//! same [`MemoryRepo`] surface.
//!
//! Gate: [`GateId::SessionWrite`].
//! Tier: supervised — executes and logs on every call.

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

use chrono::Utc;
use cortex_core::{MemoryId, OutcomeMemoryRelation};
use cortex_store::repo::{MemoryRepo, MemorySessionUse, OutcomeMemoryRelationRecord};
use cortex_store::Pool;
use serde_json::{json, Value};

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

/// Stable invariant emitted when the memory is absent or not active.
const MEMORY_NOT_FOUND_INVARIANT: &str = "memory.outcome.memory_not_found";

/// MCP tool: `cortex_memory_outcome`.
///
/// Schema:
/// ```text
/// cortex_memory_outcome(
///   memory_id:  string,
///   session_id: string,
///   result:     "helpful" | "not-helpful",
///   note?:      string,
/// ) -> { recorded: bool }
/// ```
#[derive(Debug)]
pub struct CortexMemoryOutcomeTool {
    pool: Arc<Mutex<Pool>>,
}

impl CortexMemoryOutcomeTool {
    /// 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 CortexMemoryOutcomeTool {
    fn name(&self) -> &'static str {
        "cortex_memory_outcome"
    }

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

    fn call(&self, params: Value) -> Result<Value, ToolError> {
        let memory_id_str = params["memory_id"]
            .as_str()
            .filter(|s| !s.is_empty())
            .ok_or_else(|| ToolError::InvalidParams("memory_id is required".into()))?;

        let session_id = params["session_id"]
            .as_str()
            .filter(|s| !s.trim().is_empty())
            .ok_or_else(|| ToolError::InvalidParams("session_id is required".into()))?;

        let result_str = params["result"]
            .as_str()
            .ok_or_else(|| ToolError::InvalidParams("result is required".into()))?;

        let note = params["note"].as_str().map(ToOwned::to_owned);

        let memory_id: MemoryId = memory_id_str.parse().map_err(|err| {
            ToolError::InvalidParams(format!("memory_id `{memory_id_str}` is invalid: {err}"))
        })?;

        let (relation, result_name) = match result_str {
            "helpful" => (OutcomeMemoryRelation::Used, "helpful"),
            "not-helpful" => (OutcomeMemoryRelation::Rejected, "not-helpful"),
            other => {
                return Err(ToolError::InvalidParams(format!(
                    "result must be `helpful` or `not-helpful`, got `{other}`"
                )));
            }
        };

        tracing::info!(
            "cortex_memory_outcome via MCP: memory_id={} result={}",
            memory_id,
            result_name
        );

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

        // Validate that the memory exists and is active.
        let memory = match repo.get_by_id(&memory_id) {
            Ok(Some(m)) if m.status == "active" => m,
            Ok(Some(_)) => {
                return Err(ToolError::InvalidParams(format!(
                    "{MEMORY_NOT_FOUND_INVARIANT}: memory {memory_id} exists but is not active"
                )));
            }
            Ok(None) => {
                return Err(ToolError::InvalidParams(format!(
                    "{MEMORY_NOT_FOUND_INVARIANT}: memory {memory_id} not found"
                )));
            }
            Err(err) => {
                return Err(ToolError::Internal(format!(
                    "failed to look up memory {memory_id}: {err}"
                )));
            }
        };

        let now = Utc::now();

        let session_use = MemorySessionUse {
            memory_id: memory.id,
            session_id: session_id.to_owned(),
            first_used_at: now,
            last_used_at: now,
            use_count: 1,
        };
        repo.record_session_use(&session_use)
            .map_err(|err| ToolError::Internal(format!("failed to record session use: {err}")))?;

        let outcome_ref = format!("outcome:{session_id}:{memory_id}:{result_name}");

        // evidence_ref must be None for non-validation relations (Used/Rejected);
        // the policy gate rejects non-None evidence_ref on non-Validated writes
        // (memory.outcome.utility_to_truth_promotion_unauthorized). Embed the
        // operator note in outcome_ref instead so it appears in audit records.
        let outcome_ref_with_note = match note {
            Some(ref n) if !n.is_empty() => format!("{outcome_ref}:note:{n}"),
            _ => outcome_ref.clone(),
        };

        let relation_record = OutcomeMemoryRelationRecord {
            outcome_ref: outcome_ref_with_note,
            memory_id,
            relation,
            recorded_at: now,
            source_event_id: None,
            validation_scope: None,
            validating_principal_id: None,
            evidence_ref: None,
        };

        repo.record_outcome_relation(&relation_record, None)
            .map_err(|err| {
                ToolError::Internal(format!("failed to record outcome relation: {err}"))
            })?;

        Ok(json!({ "recorded": true }))
    }
}