Skip to main content

cortex_mcp/
tool_handler.rs

1//! `ToolHandler` trait and associated types for MCP tool dispatch.
2//!
3//! Every tool in `cortex-mcp` must implement [`ToolHandler`] and declare a
4//! non-empty [`gate_set`](ToolHandler::gate_set). The [`ToolRegistry`] asserts
5//! gate wiring at registration time (DA-3): a missing gate is a fatal startup
6//! error, not a per-call error.
7//!
8//! [`ToolRegistry`]: crate::tool_registry::ToolRegistry
9
10use std::fmt;
11
12use serde_json::{json, Value};
13
14/// Logical gate IDs that a tool activates when called.
15///
16/// These map 1-to-1 with the gate equivalents enforced by the CLI. A tool
17/// that reads FTS data must declare [`GateId::FtsRead`]; a tool that writes
18/// session state must declare [`GateId::SessionWrite`] and
19/// [`GateId::CommitWrite`].
20///
21/// The full set declared here reflects the gates needed by the five stable
22/// tools defined in ADR 0045 §2. Future tools must add gate IDs here before
23/// implementing.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum GateId {
26    /// Gate for FTS5 full-text search reads (`cortex_search`).
27    FtsRead,
28    /// Gate for embedding-index reads (`cortex_search` with `semantic: true`).
29    EmbeddingRead,
30    /// Gate for context-pack reads (`cortex_context`).
31    ContextRead,
32    /// Gate for memory-health count reads (`cortex_memory_health`).
33    HealthRead,
34    /// Gate for session-event write path (`cortex_session_close`).
35    SessionWrite,
36    /// Gate for ledger/commit write path (`cortex_session_close`).
37    CommitWrite,
38}
39
40/// Errors returned from [`ToolHandler::call`].
41///
42/// Each variant maps to a JSON-RPC `-32000` application-error response.
43/// The [`serve`](crate::serve) loop converts these to the wire format.
44#[derive(Debug)]
45pub enum ToolError {
46    /// The caller supplied parameters that do not match the tool schema.
47    InvalidParams(String),
48    /// A policy gate returned `Reject`, `Quarantine`, or `BreakGlass`.
49    ///
50    /// Per ADR 0045 §3, a BreakGlass composed outcome is treated as Reject at
51    /// the MCP boundary. No write occurs when this variant is returned.
52    PolicyRejected(String),
53    /// The incoming payload exceeds the server-side size limit (RT-5).
54    SizeLimitExceeded(String),
55    /// An unexpected internal error that is not one of the above categories.
56    Internal(String),
57}
58
59impl ToolError {
60    /// Stable machine-readable refusal class for JSON-RPC `error.data`.
61    #[must_use]
62    pub const fn kind(&self) -> &'static str {
63        match self {
64            Self::InvalidParams(_) => "invalid_params",
65            Self::PolicyRejected(_) => "policy_rejected",
66            Self::SizeLimitExceeded(_) => "size_limit_exceeded",
67            Self::Internal(_) => "internal_error",
68        }
69    }
70
71    /// Structured operator-facing remediation data for MCP clients.
72    ///
73    /// The human message remains short in `error.message`; this envelope gives
74    /// clients enough deterministic shape to render a useful "what now?"
75    /// panel without weakening the refusal or inventing an override path.
76    #[must_use]
77    pub fn resolution_data(&self) -> Value {
78        json!({
79            "schema": "cortex_refusal_resolution.v1",
80            "kind": self.kind(),
81            "summary": self.summary(),
82            "detail": self.to_string(),
83            "next_actions": self.next_actions(),
84        })
85    }
86
87    fn summary(&self) -> &'static str {
88        match self {
89            Self::InvalidParams(_) => "Fix the request parameters and retry the tool.",
90            Self::PolicyRejected(_) => {
91                "Cortex preserved the policy boundary; inspect and repair the blocked input before retrying."
92            }
93            Self::SizeLimitExceeded(_) => "Reduce or split the payload, then retry.",
94            Self::Internal(_) => {
95                "An internal tool failure occurred; inspect server logs before retrying."
96            }
97        }
98    }
99
100    fn next_actions(&self) -> Vec<&'static str> {
101        match self {
102            Self::InvalidParams(_) => vec![
103                "Compare the supplied params with the tool schema.",
104                "Remove unknown fields or correct field types.",
105                "Retry the same tool with the corrected params.",
106            ],
107            Self::PolicyRejected(_) => vec![
108                "Inspect the refusal detail for the blocked invariant or policy outcome.",
109                "Use read-only tools such as cortex_memory_health, cortex_memory_list, or cortex_search to find the affected memory.",
110                "Repair, re-admit, or mark the memory outcome through the appropriate Cortex flow.",
111                "Retry the original tool after the policy outcome is no longer Reject, Quarantine, or BreakGlass.",
112            ],
113            Self::SizeLimitExceeded(_) => vec![
114                "Split the payload into smaller batches.",
115                "Keep each request under the documented tool size limit.",
116                "Retry with the reduced payload.",
117            ],
118            Self::Internal(_) => vec![
119                "Inspect Cortex MCP stderr/server logs for the full diagnostic.",
120                "Retry only after confirming the backing store and runtime are healthy.",
121            ],
122        }
123    }
124}
125
126impl fmt::Display for ToolError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            ToolError::InvalidParams(msg) => write!(f, "invalid params: {msg}"),
130            ToolError::PolicyRejected(msg) => write!(f, "policy rejected: {msg}"),
131            ToolError::SizeLimitExceeded(msg) => write!(f, "size limit exceeded: {msg}"),
132            ToolError::Internal(msg) => write!(f, "internal error: {msg}"),
133        }
134    }
135}
136
137/// A single MCP tool that can be dispatched by the [`ToolRegistry`].
138///
139/// Implementors must be `Send + Sync` because the registry may be held across
140/// thread boundaries in future async-capable transports.
141///
142/// [`ToolRegistry`]: crate::tool_registry::ToolRegistry
143pub trait ToolHandler: Send + Sync {
144    /// The JSON-RPC method name this handler responds to.
145    ///
146    /// Must be unique across all handlers registered in the same
147    /// [`ToolRegistry`]. The convention is `cortex_<verb>`.
148    fn name(&self) -> &'static str;
149
150    /// Gate IDs this tool activates.
151    ///
152    /// Must be non-empty. [`ToolRegistry::register`] asserts this at
153    /// registration time (DA-3).
154    ///
155    /// [`ToolRegistry::register`]: crate::tool_registry::ToolRegistry::register
156    fn gate_set(&self) -> &'static [GateId];
157
158    /// Execute the tool with the given JSON params, returning a JSON result.
159    ///
160    /// The `params` value is the raw `"params"` field from the JSON-RPC
161    /// request. If the request omitted `"params"`, the serve loop passes
162    /// `serde_json::Value::Null`.
163    fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError>;
164}