grex_mcp/error.rs
1//! Error mapping from grex-core failure types to MCP error envelopes.
2//!
3//! Two distinct error surfaces co-exist:
4//!
5//! 1. **JSON-RPC envelope errors** — returned as `Err(ErrorData)` from a
6//! handler. The dispatcher emits a top-level JSON-RPC error response.
7//! Used for: cancellation (-32800), bad params (-32602), and rmcp-state
8//! errors like "not initialized" (-32002, kind=`init_state`).
9//!
10//! 2. **Tool-level failures inside `CallToolResult`** — `Ok(CallToolResult
11//! { isError: Some(true), content: [...] })`. The handler completed; the
12//! domain operation failed. Used for grex pack-op failures (-32002,
13//! kind=`pack_op`) and the spec's manifest / lock / drift / plugin-
14//! missing codes.
15//!
16//! Per `.omne/cfg/mcp.md` §"Error codes" `-32002` is dual-use; the `data.kind`
17//! discriminator disambiguates. Splitting into two codes is a documented
18//! future item — not in this change.
19
20use grex_core::Cancelled;
21use rmcp::{
22 model::{CallToolResult, Content, ErrorCode},
23 ErrorData,
24};
25use serde_json::{json, Value};
26
27// ── JSON-RPC reserved codes ─────────────────────────────────────────────
28
29/// MCP "Request cancelled" per the 2025-06-18 specification.
30pub const REQUEST_CANCELLED: i32 = -32800;
31
32/// JSON-RPC "Invalid params" per the 2.0 spec.
33pub const INVALID_PARAMS: i32 = -32602;
34
35// ── grex error-code surface (spec §"Error codes") ───────────────────────
36
37/// Manifest read / parse / write failure.
38pub const MANIFEST_ERROR: i32 = -32001;
39
40/// Pack-op or init-state failure (dual-use, see file header).
41pub const POLICY_ERROR: i32 = -32002;
42
43/// Pack-lock acquisition failure.
44pub const LOCK_ERROR: i32 = -32003;
45
46/// Lockfile drift detected.
47pub const DRIFT_ERROR: i32 = -32004;
48
49/// Plugin-missing failure (action or pack-type).
50pub const PLUGIN_MISSING: i32 = -32005;
51
52// ── Cancellation → -32800 (envelope) ────────────────────────────────────
53
54/// Convert grex-core's [`Cancelled`] sentinel into an MCP envelope.
55impl From<CancelledExt> for ErrorData {
56 fn from(_: CancelledExt) -> Self {
57 ErrorData::new(ErrorCode(REQUEST_CANCELLED), "request cancelled", None)
58 }
59}
60
61/// New-type wrapper because `Cancelled` is foreign and we cannot
62/// `impl From<Cancelled> for ErrorData` directly (orphan rule).
63#[derive(Debug, Clone, Copy, Default)]
64pub struct CancelledExt;
65
66impl From<Cancelled> for CancelledExt {
67 fn from(_: Cancelled) -> Self {
68 CancelledExt
69 }
70}
71
72// ── Pack-op failure → -32002 inside CallToolResult ──────────────────────
73
74/// Build a `CallToolResult { isError: true }` carrying a structured
75/// error description with `data.kind = "pack_op"` and code `-32002`.
76///
77/// The MCP spec puts tool-domain failures inside the result envelope
78/// rather than at the JSON-RPC layer. We attach the code + kind in
79/// the `Content::text` body as serialised JSON so agents can parse
80/// without an out-of-band channel.
81pub fn packop_error(message: &str) -> CallToolResult {
82 let body = json!({
83 "code": POLICY_ERROR,
84 "data": { "kind": "pack_op" },
85 "message": message,
86 });
87 CallToolResult::error(vec![Content::text(body.to_string())])
88}
89
90/// Build an `ErrorData` for an init-state failure (-32002, kind=
91/// `init_state`). Used at the JSON-RPC envelope layer when a request
92/// arrives before `initialize` has completed.
93pub fn init_state_error(message: impl Into<String>) -> ErrorData {
94 let data: Value = json!({ "kind": "init_state" });
95 ErrorData::new(ErrorCode(POLICY_ERROR), message.into(), Some(data))
96}
97
98/// Build a `CallToolResult { isError: true }` for the "verb not yet
99/// implemented" stub path used by Stage 6's nine stub verbs.
100///
101/// Choice (per `feat-m7-1-mcp-server/tasks.md` Stage 6 Option A):
102/// stub verbs are advertised in `tools/list` so agents can discover the
103/// surface, but every call returns an isError envelope with code
104/// `-32601 Method Not Implemented` and a human-readable hint. Swap to
105/// real impls is local in M7-4.
106pub fn not_implemented_result(verb: &str) -> CallToolResult {
107 let body = json!({
108 "code": -32601,
109 "data": { "kind": "not_implemented" },
110 "message": format!("verb `{verb}` not yet implemented in M7-1; planned for M7-4"),
111 });
112 CallToolResult::error(vec![Content::text(body.to_string())])
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 /// 6.T7 — preserved from Stage 5; cancellation still maps to -32800.
120 #[test]
121 fn cancelled_maps_to_minus_32800() {
122 let err: ErrorData = CancelledExt::from(Cancelled).into();
123 assert_eq!(err.code.0, REQUEST_CANCELLED);
124 assert!(err.message.contains("cancel"));
125 }
126
127 /// 6.T5 — pack-op failures go inside `CallToolResult { isError }`
128 /// with code `-32002` and `data.kind = "pack_op"`.
129 #[test]
130 fn packop_failure_maps_to_minus_32002_with_kind_pack_op() {
131 let r = packop_error("disk full");
132 assert_eq!(r.is_error, Some(true));
133 let text = r.content.first().expect("content").as_text().expect("text").text.clone();
134 let v: Value = serde_json::from_str(&text).expect("payload is JSON");
135 assert_eq!(v["code"], json!(POLICY_ERROR));
136 assert_eq!(v["data"]["kind"], json!("pack_op"));
137 assert!(v["message"].as_str().unwrap().contains("disk full"));
138 }
139
140 /// 6.T6 — init-state failures live at the JSON-RPC envelope layer
141 /// (`Err(ErrorData)`) with code `-32002` and `data.kind = "init_state"`.
142 #[test]
143 fn init_state_failure_maps_to_minus_32002_with_kind_init_state() {
144 let err = init_state_error("not initialised");
145 assert_eq!(err.code.0, POLICY_ERROR);
146 let data = err.data.as_ref().expect("data attached");
147 assert_eq!(data["kind"], json!("init_state"));
148 }
149
150 #[test]
151 fn not_implemented_envelope_carries_minus_32601_and_kind() {
152 let r = not_implemented_result("ls");
153 assert_eq!(r.is_error, Some(true));
154 let text = r.content.first().expect("content").as_text().expect("text").text.clone();
155 let v: Value = serde_json::from_str(&text).expect("payload is JSON");
156 assert_eq!(v["code"], json!(-32601));
157 assert_eq!(v["data"]["kind"], json!("not_implemented"));
158 assert!(v["message"].as_str().unwrap().contains("ls"));
159 }
160}