crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `ToolHandler` trait and associated types for MCP tool dispatch.
//!
//! Every tool in `cortex-mcp` must implement [`ToolHandler`] and declare a
//! non-empty [`gate_set`](ToolHandler::gate_set). The [`ToolRegistry`] asserts
//! gate wiring at registration time (DA-3): a missing gate is a fatal startup
//! error, not a per-call error.
//!
//! [`ToolRegistry`]: crate::tool_registry::ToolRegistry

use std::fmt;

use serde_json::{json, Value};

/// Logical gate IDs that a tool activates when called.
///
/// These map 1-to-1 with the gate equivalents enforced by the CLI. A tool
/// that reads FTS data must declare [`GateId::FtsRead`]; a tool that writes
/// session state must declare [`GateId::SessionWrite`] and
/// [`GateId::CommitWrite`].
///
/// The full set declared here reflects the gates needed by the five stable
/// tools defined in ADR 0045 §2. Future tools must add gate IDs here before
/// implementing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GateId {
    /// Gate for FTS5 full-text search reads (`cortex_search`).
    FtsRead,
    /// Gate for embedding-index reads (`cortex_search` with `semantic: true`).
    EmbeddingRead,
    /// Gate for context-pack reads (`cortex_context`).
    ContextRead,
    /// Gate for memory-health count reads (`cortex_memory_health`).
    HealthRead,
    /// Gate for session-event write path (`cortex_session_close`).
    SessionWrite,
    /// Gate for ledger/commit write path (`cortex_session_close`).
    CommitWrite,
}

/// Errors returned from [`ToolHandler::call`].
///
/// Each variant maps to a JSON-RPC `-32000` application-error response.
/// The [`serve`](crate::serve) loop converts these to the wire format.
#[derive(Debug)]
pub enum ToolError {
    /// The caller supplied parameters that do not match the tool schema.
    InvalidParams(String),
    /// A policy gate returned `Reject`, `Quarantine`, or `BreakGlass`.
    ///
    /// Per ADR 0045 §3, a BreakGlass composed outcome is treated as Reject at
    /// the MCP boundary. No write occurs when this variant is returned.
    PolicyRejected(String),
    /// The incoming payload exceeds the server-side size limit (RT-5).
    SizeLimitExceeded(String),
    /// An unexpected internal error that is not one of the above categories.
    Internal(String),
}

impl ToolError {
    /// Stable machine-readable refusal class for JSON-RPC `error.data`.
    #[must_use]
    pub const fn kind(&self) -> &'static str {
        match self {
            Self::InvalidParams(_) => "invalid_params",
            Self::PolicyRejected(_) => "policy_rejected",
            Self::SizeLimitExceeded(_) => "size_limit_exceeded",
            Self::Internal(_) => "internal_error",
        }
    }

    /// Structured operator-facing remediation data for MCP clients.
    ///
    /// The human message remains short in `error.message`; this envelope gives
    /// clients enough deterministic shape to render a useful "what now?"
    /// panel without weakening the refusal or inventing an override path.
    #[must_use]
    pub fn resolution_data(&self) -> Value {
        json!({
            "schema": "cortex_refusal_resolution.v1",
            "kind": self.kind(),
            "summary": self.summary(),
            "detail": self.to_string(),
            "next_actions": self.next_actions(),
        })
    }

    fn summary(&self) -> &'static str {
        match self {
            Self::InvalidParams(_) => "Fix the request parameters and retry the tool.",
            Self::PolicyRejected(_) => {
                "Cortex preserved the policy boundary; inspect and repair the blocked input before retrying."
            }
            Self::SizeLimitExceeded(_) => "Reduce or split the payload, then retry.",
            Self::Internal(_) => {
                "An internal tool failure occurred; inspect server logs before retrying."
            }
        }
    }

    fn next_actions(&self) -> Vec<&'static str> {
        match self {
            Self::InvalidParams(_) => vec![
                "Compare the supplied params with the tool schema.",
                "Remove unknown fields or correct field types.",
                "Retry the same tool with the corrected params.",
            ],
            Self::PolicyRejected(_) => vec![
                "Inspect the refusal detail for the blocked invariant or policy outcome.",
                "Use read-only tools such as cortex_memory_health, cortex_memory_list, or cortex_search to find the affected memory.",
                "Repair, re-admit, or mark the memory outcome through the appropriate Cortex flow.",
                "Retry the original tool after the policy outcome is no longer Reject, Quarantine, or BreakGlass.",
            ],
            Self::SizeLimitExceeded(_) => vec![
                "Split the payload into smaller batches.",
                "Keep each request under the documented tool size limit.",
                "Retry with the reduced payload.",
            ],
            Self::Internal(_) => vec![
                "Inspect Cortex MCP stderr/server logs for the full diagnostic.",
                "Retry only after confirming the backing store and runtime are healthy.",
            ],
        }
    }
}

impl fmt::Display for ToolError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ToolError::InvalidParams(msg) => write!(f, "invalid params: {msg}"),
            ToolError::PolicyRejected(msg) => write!(f, "policy rejected: {msg}"),
            ToolError::SizeLimitExceeded(msg) => write!(f, "size limit exceeded: {msg}"),
            ToolError::Internal(msg) => write!(f, "internal error: {msg}"),
        }
    }
}

/// A single MCP tool that can be dispatched by the [`ToolRegistry`].
///
/// Implementors must be `Send + Sync` because the registry may be held across
/// thread boundaries in future async-capable transports.
///
/// [`ToolRegistry`]: crate::tool_registry::ToolRegistry
pub trait ToolHandler: Send + Sync {
    /// The JSON-RPC method name this handler responds to.
    ///
    /// Must be unique across all handlers registered in the same
    /// [`ToolRegistry`]. The convention is `cortex_<verb>`.
    fn name(&self) -> &'static str;

    /// Gate IDs this tool activates.
    ///
    /// Must be non-empty. [`ToolRegistry::register`] asserts this at
    /// registration time (DA-3).
    ///
    /// [`ToolRegistry::register`]: crate::tool_registry::ToolRegistry::register
    fn gate_set(&self) -> &'static [GateId];

    /// Execute the tool with the given JSON params, returning a JSON result.
    ///
    /// The `params` value is the raw `"params"` field from the JSON-RPC
    /// request. If the request omitted `"params"`, the serve loop passes
    /// `serde_json::Value::Null`.
    fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError>;
}