use serde::{Deserialize, Serialize};
pub const MAX_ENVELOPE_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
pub method: Option<String>,
pub id: Option<String>,
pub target: Option<String>,
pub is_notification: bool,
pub arguments: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
TooLarge {
size: usize,
},
NotJson,
NotAnObject,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooLarge { size } => write!(
f,
"body is {size} bytes, above the {MAX_ENVELOPE_BYTES} byte inspection limit"
),
Self::NotJson => write!(f, "body is not valid JSON"),
Self::NotAnObject => write!(f, "body is not a JSON-RPC object"),
}
}
}
pub fn parse(body: &[u8]) -> Result<Envelope, ParseError> {
if body.len() > MAX_ENVELOPE_BYTES {
return Err(ParseError::TooLarge { size: body.len() });
}
let value: serde_json::Value = serde_json::from_slice(body).map_err(|_| ParseError::NotJson)?;
let object = value.as_object().ok_or(ParseError::NotAnObject)?;
let method = object
.get("method")
.and_then(|m| m.as_str())
.map(str::to_string);
let raw_id = object.get("id").filter(|id| !id.is_null());
let id = raw_id.map(|id| match id {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
});
let target = object
.get("params")
.and_then(|params| params.as_object())
.and_then(|params| {
params
.get("name")
.or_else(|| params.get("uri"))
.and_then(|v| v.as_str())
})
.map(str::to_string);
let arguments = object
.get("params")
.and_then(|params| params.as_object())
.and_then(|params| params.get("arguments"))
.and_then(|arguments| arguments.as_object())
.map(|arguments| {
arguments
.iter()
.filter_map(|(key, value)| render_argument(value).map(|v| (key.clone(), v)))
.collect()
})
.unwrap_or_default();
Ok(Envelope {
method,
is_notification: raw_id.is_none(),
id,
target,
arguments,
})
}
fn render_argument(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Bool(b) => Some(b.to_string()),
serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => Some(n.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_method_id_and_tool_name() {
let body = br#"{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "get_weather", "arguments": { "location": "Seattle" } }
}"#;
let envelope = parse(body).expect("should parse");
assert_eq!(envelope.method.as_deref(), Some("tools/call"));
assert_eq!(envelope.id.as_deref(), Some("1"));
assert_eq!(envelope.target.as_deref(), Some("get_weather"));
assert!(!envelope.is_notification);
}
#[test]
fn falls_back_to_params_uri() {
let body = br#"{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": { "uri": "file:///etc/passwd" }
}"#;
let envelope = parse(body).expect("should parse");
assert_eq!(envelope.target.as_deref(), Some("file:///etc/passwd"));
}
#[test]
fn prefers_name_over_uri_when_both_are_present() {
let body = br#"{"jsonrpc":"2.0","id":1,"method":"m","params":{"name":"n","uri":"u"}}"#;
assert_eq!(parse(body).unwrap().target.as_deref(), Some("n"));
}
#[test]
fn a_string_id_survives_as_written() {
let body = br#"{"jsonrpc":"2.0","id":"req-abc","method":"tools/list"}"#;
assert_eq!(parse(body).unwrap().id.as_deref(), Some("req-abc"));
}
#[test]
fn a_message_without_an_id_is_a_notification() {
let body = br#"{"jsonrpc":"2.0","method":"notifications/progress"}"#;
let envelope = parse(body).expect("should parse");
assert!(envelope.is_notification);
assert_eq!(envelope.id, None);
}
#[test]
fn an_explicit_null_id_is_still_a_notification() {
let body = br#"{"jsonrpc":"2.0","id":null,"method":"notifications/progress"}"#;
let envelope = parse(body).expect("should parse");
assert!(envelope.is_notification);
assert_eq!(envelope.id, None);
}
#[test]
fn missing_params_is_not_an_error() {
let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
let envelope = parse(body).expect("should parse");
assert_eq!(envelope.method.as_deref(), Some("tools/list"));
assert_eq!(envelope.target, None);
}
#[test]
fn unrecognised_fields_are_ignored() {
let body = br#"{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "t", "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
"somethingEntirelyNew": { "nested": [1, 2, 3] }
}"#;
let envelope = parse(body).expect("unknown fields must not fail the parse");
assert_eq!(envelope.target.as_deref(), Some("t"));
}
#[test]
fn a_body_above_the_limit_is_refused_rather_than_parsed() {
let body = vec![b'x'; MAX_ENVELOPE_BYTES + 1];
assert_eq!(
parse(&body),
Err(ParseError::TooLarge {
size: MAX_ENVELOPE_BYTES + 1
})
);
}
#[test]
fn invalid_json_is_reported_as_such() {
assert_eq!(parse(b"{not json"), Err(ParseError::NotJson));
}
#[test]
fn a_batch_array_is_reported_as_not_an_object() {
let body = br#"[{"jsonrpc":"2.0","id":1,"method":"tools/call"}]"#;
assert_eq!(parse(body), Err(ParseError::NotAnObject));
}
#[test]
fn an_empty_body_is_not_json() {
assert_eq!(parse(b""), Err(ParseError::NotJson));
}
}