Skip to main content

mcpr_core/protocol/
jsonrpc.rs

1//! Shallow JSON-RPC 2.0 envelope.
2//!
3//! [`JsonRpcEnvelope::parse`] extracts `jsonrpc`, `id`, `method`,
4//! `params`, `result`, `error` in one pass. `params` and `result` stay
5//! as [`RawValue`] — unparsed bytes — until a caller opts in to a typed
6//! view via [`JsonRpcEnvelope::params_as`] or
7//! [`JsonRpcEnvelope::result_as`].
8//!
9//! [`JsonRpcEnvelope::to_bytes`] is the inverse — reassemble the
10//! envelope into JSON bytes, typically before forwarding.
11//!
12//! MCP 2025-11-25 does not batch, so `parse` rejects top-level JSON
13//! arrays with [`ParseError::InvalidShape`].
14
15use serde::{Deserialize, Deserializer, de::DeserializeOwned};
16use serde_json::value::RawValue;
17use serde_json::{Map, Value};
18
19/// Shallow parse of a single JSON-RPC 2.0 message.
20#[derive(Debug, Clone)]
21pub struct JsonRpcEnvelope {
22    pub id: Option<JsonRpcId>,
23    pub method: Option<String>,
24    pub params: Option<Box<RawValue>>,
25    pub result: Option<Box<RawValue>>,
26    pub error: Option<JsonRpcError>,
27}
28
29/// JSON-RPC id. `Null` is valid per spec for replies to un-parseable
30/// requests. Absent id (request-without-id, i.e. a notification) is
31/// `None` on the envelope's `id` field.
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub enum JsonRpcId {
34    Number(i64),
35    String(String),
36    Null,
37}
38
39/// JSON-RPC error object. `data` stays as raw bytes so middlewares pay
40/// no cost when they don't inspect it.
41#[derive(Debug, Clone)]
42pub struct JsonRpcError {
43    pub code: i32,
44    pub message: String,
45    pub data: Option<Box<RawValue>>,
46}
47
48/// Reason [`JsonRpcEnvelope::parse`] declined the bytes. These are soft
49/// signals for the intake layer — a `NotJson` body is not an error to
50/// the proxy, just a hint to try the next classification branch.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum ParseError {
53    /// Bytes are not valid JSON.
54    NotJson,
55    /// Valid JSON, but missing or wrong `jsonrpc` field.
56    NotJsonRpc20,
57    /// Valid JSON-RPC 2.0 object, but the field combination does not
58    /// match any of the four kinds (Request, Notification, Result,
59    /// Error). Batches (top-level arrays) land here.
60    InvalidShape,
61}
62
63#[derive(Deserialize)]
64struct Raw {
65    #[serde(default)]
66    jsonrpc: Option<String>,
67    // `some_value` preserves JSON `null` as `Some(Value::Null)` instead
68    // of collapsing it to `None` — needed to distinguish an absent id
69    // from an explicit null id (spec-legal reply to an un-parseable
70    // request).
71    #[serde(default, deserialize_with = "some_value")]
72    id: Option<serde_json::Value>,
73    #[serde(default)]
74    method: Option<String>,
75    #[serde(default)]
76    params: Option<Box<RawValue>>,
77    #[serde(default)]
78    result: Option<Box<RawValue>>,
79    #[serde(default)]
80    error: Option<RawError>,
81}
82
83fn some_value<'de, D>(d: D) -> Result<Option<serde_json::Value>, D::Error>
84where
85    D: Deserializer<'de>,
86{
87    serde_json::Value::deserialize(d).map(Some)
88}
89
90#[derive(Deserialize)]
91struct RawError {
92    code: i32,
93    message: String,
94    #[serde(default)]
95    data: Option<Box<RawValue>>,
96}
97
98impl JsonRpcEnvelope {
99    /// Parse a single JSON-RPC 2.0 message. Rejects batches.
100    pub fn parse(bytes: &[u8]) -> Result<Self, ParseError> {
101        if first_non_ws(bytes) == Some(b'[') {
102            return Err(ParseError::InvalidShape);
103        }
104
105        let raw: Raw = serde_json::from_slice(bytes).map_err(|_| ParseError::NotJson)?;
106
107        if raw.jsonrpc.as_deref() != Some("2.0") {
108            return Err(ParseError::NotJsonRpc20);
109        }
110
111        let id = match raw.id {
112            None => None,
113            Some(serde_json::Value::Null) => Some(JsonRpcId::Null),
114            Some(serde_json::Value::Number(n)) => Some(JsonRpcId::Number(
115                n.as_i64().ok_or(ParseError::InvalidShape)?,
116            )),
117            Some(serde_json::Value::String(s)) => Some(JsonRpcId::String(s)),
118            Some(_) => return Err(ParseError::InvalidShape),
119        };
120
121        let error = raw.error.map(|e| JsonRpcError {
122            code: e.code,
123            message: e.message,
124            data: e.data,
125        });
126
127        let shape = (
128            raw.method.is_some(),
129            id.is_some(),
130            raw.result.is_some(),
131            error.is_some(),
132        );
133        // The four legal shapes: request, notification, result reply, error reply.
134        let valid = matches!(
135            shape,
136            (true,  true,  false, false)  // request
137            | (true,  false, false, false) // notification
138            | (false, true,  true,  false) // result reply
139            | (false, true,  false, true) // error reply
140        );
141        if !valid {
142            return Err(ParseError::InvalidShape);
143        }
144
145        Ok(JsonRpcEnvelope {
146            id,
147            method: raw.method,
148            params: raw.params,
149            result: raw.result,
150            error,
151        })
152    }
153
154    /// Deserialize `params` into `T`. Returns `None` if `params` is
155    /// absent or does not match `T`'s shape.
156    pub fn params_as<T: DeserializeOwned>(&self) -> Option<T> {
157        let raw = self.params.as_ref()?;
158        serde_json::from_str(raw.get()).ok()
159    }
160
161    /// Deserialize `result` into `T`. Returns `None` if `result` is
162    /// absent or does not match `T`'s shape.
163    pub fn result_as<T: DeserializeOwned>(&self) -> Option<T> {
164        let raw = self.result.as_ref()?;
165        serde_json::from_str(raw.get()).ok()
166    }
167
168    /// Reassemble the envelope into JSON-RPC 2.0 bytes.
169    ///
170    /// Used when forwarding upstream (request body) and when sealing a
171    /// buffered response. The inner `RawValue` fields are re-inlined
172    /// verbatim — no re-parsing. Parse failures on the cached bytes
173    /// would have been caught by [`JsonRpcEnvelope::parse`], so we
174    /// default to `Null` rather than panic.
175    pub fn to_bytes(&self) -> Vec<u8> {
176        let mut map = Map::with_capacity(5);
177        map.insert("jsonrpc".into(), Value::String("2.0".into()));
178        if let Some(id) = &self.id {
179            map.insert("id".into(), id_to_value(id));
180        }
181        if let Some(method) = &self.method {
182            map.insert("method".into(), Value::String(method.clone()));
183        }
184        if let Some(params) = &self.params {
185            map.insert(
186                "params".into(),
187                serde_json::from_str(params.get()).unwrap_or(Value::Null),
188            );
189        }
190        if let Some(result) = &self.result {
191            map.insert(
192                "result".into(),
193                serde_json::from_str(result.get()).unwrap_or(Value::Null),
194            );
195        }
196        if let Some(error) = &self.error {
197            let mut err = Map::with_capacity(3);
198            err.insert("code".into(), Value::Number((error.code as i64).into()));
199            err.insert("message".into(), Value::String(error.message.clone()));
200            if let Some(data) = &error.data {
201                err.insert(
202                    "data".into(),
203                    serde_json::from_str(data.get()).unwrap_or(Value::Null),
204                );
205            }
206            map.insert("error".into(), Value::Object(err));
207        }
208        serde_json::to_vec(&Value::Object(map)).unwrap_or_default()
209    }
210}
211
212fn id_to_value(id: &JsonRpcId) -> Value {
213    match id {
214        JsonRpcId::Number(n) => Value::Number((*n).into()),
215        JsonRpcId::String(s) => Value::String(s.clone()),
216        JsonRpcId::Null => Value::Null,
217    }
218}
219
220fn first_non_ws(bytes: &[u8]) -> Option<u8> {
221    bytes.iter().copied().find(|b| !b.is_ascii_whitespace())
222}
223
224#[cfg(test)]
225#[allow(non_snake_case)]
226mod tests {
227    use super::*;
228    use serde::Deserialize;
229
230    #[derive(Debug, Deserialize, PartialEq)]
231    struct Greet {
232        name: String,
233    }
234
235    // ── parse happy paths ─────────────────────────────────────
236
237    #[test]
238    fn parse__request_shape() {
239        let env = JsonRpcEnvelope::parse(
240            br#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"x":1}}"#,
241        )
242        .unwrap();
243        assert_eq!(env.id, Some(JsonRpcId::Number(1)));
244        assert_eq!(env.method.as_deref(), Some("tools/list"));
245        assert!(env.params.is_some());
246        assert!(env.result.is_none());
247        assert!(env.error.is_none());
248    }
249
250    #[test]
251    fn parse__notification_shape() {
252        let env = JsonRpcEnvelope::parse(
253            br#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"p":0.5}}"#,
254        )
255        .unwrap();
256        assert!(env.id.is_none());
257        assert_eq!(env.method.as_deref(), Some("notifications/progress"));
258    }
259
260    #[test]
261    fn parse__result_shape() {
262        let env =
263            JsonRpcEnvelope::parse(br#"{"jsonrpc":"2.0","id":"r1","result":{"ok":true}}"#).unwrap();
264        assert_eq!(env.id, Some(JsonRpcId::String("r1".into())));
265        assert!(env.result.is_some());
266    }
267
268    #[test]
269    fn parse__error_shape() {
270        let env = JsonRpcEnvelope::parse(
271            br#"{"jsonrpc":"2.0","id":7,"error":{"code":-32600,"message":"invalid"}}"#,
272        )
273        .unwrap();
274        let err = env.error.unwrap();
275        assert_eq!(err.code, -32600);
276        assert_eq!(err.message, "invalid");
277    }
278
279    #[test]
280    fn parse__null_id_accepted() {
281        let env = JsonRpcEnvelope::parse(
282            br#"{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"parse error"}}"#,
283        )
284        .unwrap();
285        assert_eq!(env.id, Some(JsonRpcId::Null));
286    }
287
288    #[test]
289    fn parse__id_fractional_number_rejected() {
290        let err =
291            JsonRpcEnvelope::parse(br#"{"jsonrpc":"2.0","id":1.5,"method":"x"}"#).unwrap_err();
292        assert_eq!(err, ParseError::InvalidShape);
293    }
294
295    // ── parse error cases ─────────────────────────────────────
296
297    #[test]
298    fn parse__empty_body_returns_not_json() {
299        assert_eq!(
300            JsonRpcEnvelope::parse(b"").unwrap_err(),
301            ParseError::NotJson
302        );
303    }
304
305    #[test]
306    fn parse__garbage_bytes_return_not_json() {
307        assert_eq!(
308            JsonRpcEnvelope::parse(b"not json at all").unwrap_err(),
309            ParseError::NotJson,
310        );
311    }
312
313    #[test]
314    fn parse__missing_jsonrpc_field() {
315        let err = JsonRpcEnvelope::parse(br#"{"id":1,"method":"foo"}"#).unwrap_err();
316        assert_eq!(err, ParseError::NotJsonRpc20);
317    }
318
319    #[test]
320    fn parse__wrong_jsonrpc_version() {
321        let err =
322            JsonRpcEnvelope::parse(br#"{"jsonrpc":"1.0","id":1,"method":"foo"}"#).unwrap_err();
323        assert_eq!(err, ParseError::NotJsonRpc20);
324    }
325
326    #[test]
327    fn parse__bare_jsonrpc_is_invalid_shape() {
328        let err = JsonRpcEnvelope::parse(br#"{"jsonrpc":"2.0"}"#).unwrap_err();
329        assert_eq!(err, ParseError::InvalidShape);
330    }
331
332    #[test]
333    fn parse__top_level_array_rejected() {
334        let err = JsonRpcEnvelope::parse(br#"[{"jsonrpc":"2.0","method":"x"}]"#).unwrap_err();
335        assert_eq!(err, ParseError::InvalidShape);
336    }
337
338    #[test]
339    fn parse__top_level_array_with_leading_ws_rejected() {
340        let err = JsonRpcEnvelope::parse(b"   [ ]").unwrap_err();
341        assert_eq!(err, ParseError::InvalidShape);
342    }
343
344    #[test]
345    fn parse__both_result_and_error_rejected() {
346        let err = JsonRpcEnvelope::parse(
347            br#"{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-1,"message":"x"}}"#,
348        )
349        .unwrap_err();
350        assert_eq!(err, ParseError::InvalidShape);
351    }
352
353    #[test]
354    fn parse__response_without_id_rejected() {
355        let err = JsonRpcEnvelope::parse(br#"{"jsonrpc":"2.0","result":{}}"#).unwrap_err();
356        assert_eq!(err, ParseError::InvalidShape);
357    }
358
359    // ── params_as / result_as ─────────────────────────────────
360
361    #[test]
362    fn params_as__deserializes_on_match() {
363        let env = JsonRpcEnvelope::parse(
364            br#"{"jsonrpc":"2.0","id":1,"method":"greet","params":{"name":"rod"}}"#,
365        )
366        .unwrap();
367        assert_eq!(env.params_as::<Greet>(), Some(Greet { name: "rod".into() }));
368    }
369
370    #[test]
371    fn params_as__none_on_mismatch() {
372        let env = JsonRpcEnvelope::parse(
373            br#"{"jsonrpc":"2.0","id":1,"method":"greet","params":{"wrong":1}}"#,
374        )
375        .unwrap();
376        assert!(env.params_as::<Greet>().is_none());
377    }
378
379    #[test]
380    fn params_as__none_when_absent() {
381        let env = JsonRpcEnvelope::parse(br#"{"jsonrpc":"2.0","id":1,"method":"greet"}"#).unwrap();
382        assert!(env.params_as::<Greet>().is_none());
383    }
384
385    #[test]
386    fn result_as__deserializes_on_match() {
387        let env =
388            JsonRpcEnvelope::parse(br#"{"jsonrpc":"2.0","id":1,"result":{"name":"rod"}}"#).unwrap();
389        assert_eq!(env.result_as::<Greet>(), Some(Greet { name: "rod".into() }));
390    }
391}