Skip to main content

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}