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}