use proptest::prelude::*;
use protocol_droid::adapters::secondary::mcp::McpMethodTable;
use protocol_droid::application::jsonrpc::{
AllowBatching, MethodTable, ProfileBatching, route_frame,
};
use protocol_droid::domain::RequestId;
use protocol_droid::domain::feature::FeatureProfile;
use protocol_droid::domain::jsonrpc::{Frame, WireId, decode};
fn open() -> FeatureProfile {
FeatureProfile::all()
}
proptest! {
#[test]
fn the_decoder_never_panics_on_arbitrary_bytes(
bytes in prop::collection::vec(any::<u8>(), 0..512),
) {
if let Ok(frame) = decode(&bytes) {
let _ = format!("{frame:?}");
for envelope in frame.envelopes() {
let _ = envelope.kind();
let _ = envelope.method();
let _ = envelope.id();
let _ = envelope.params().map(|params| params.get().len());
let _ = envelope.outcome();
let _ = envelope.routeable();
if let Some(id) = envelope.id() {
let _ = id.to_json();
}
}
}
}
#[test]
fn routing_never_panics_on_arbitrary_bytes(
bytes in prop::collection::vec(any::<u8>(), 0..512),
) {
let profile = open();
let refusals = [
route_frame(&bytes, &McpMethodTable::TABLE, &profile, &AllowBatching).err(),
route_frame(&bytes, &McpMethodTable::TABLE, &profile, &ProfileBatching).err(),
];
for error in refusals.into_iter().flatten() {
let _ = error.code().code();
}
}
#[test]
fn routing_never_panics_on_arbitrary_member_values(
method in prop::sample::select(vec![
"ping",
"tools/call",
"tools/list",
"notifications/initialized",
"frobnicate",
"",
]),
id in prop::sample::select(vec![
r#""1""#, "1", "-1", "null", "true", r#"{"a":1}"#, "[1]", "1.5", "0", r#""""#,
]),
params in prop::option::of(prop::sample::select(vec![
"null", "{}", r#"{"name":"x"}"#, "[]", r#""text""#, "1",
])),
) {
let params = params.map_or_else(String::new, |value| format!(r#","params":{value}"#));
let input = format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}"{params}}}"#);
let _ = decode(input.as_bytes());
let _ = route_frame(input.as_bytes(), &McpMethodTable::TABLE, &open(), &AllowBatching);
}
#[test]
fn a_numeric_id_survives_the_round_trip(number in any::<i64>()) {
let input = format!(r#"{{"jsonrpc":"2.0","id":{number},"method":"ping"}}"#);
if let Ok(Frame::Single(envelope)) = decode(input.as_bytes()) {
if let Some(value) = envelope.id().and_then(WireId::as_number) {
prop_assert_eq!(value, number);
let rendered = value.to_string();
let parsed = serde_json::from_str::<i64>(&rendered);
prop_assert!(parsed.is_ok(), "a rendered i64 is a JSON number");
prop_assert_eq!(parsed.unwrap_or(0), number);
} else {
prop_assert!(false, "a numeric id must decode as a number");
}
} else {
prop_assert!(false, "a request with a valid id must decode");
}
}
#[test]
fn a_string_id_survives_the_round_trip(text in ".{0,64}") {
if let Ok(encoded) = serde_json::to_string(&text) {
let input = format!(r#"{{"jsonrpc":"2.0","id":{encoded},"method":"ping"}}"#);
if let Ok(Frame::Single(envelope)) = decode(input.as_bytes()) {
if let Some(WireId::Text(borrowed)) = envelope.id() {
prop_assert_eq!(borrowed, text.as_str());
prop_assert_eq!(RequestId::text(borrowed), RequestId::text(text.as_str()));
} else {
prop_assert!(false, "a string id must decode as a string");
}
} else {
prop_assert!(false, "a request with a valid id must decode");
}
} else {
prop_assert!(false, "a string always serialises");
}
}
#[test]
fn a_written_id_is_always_valid_json(text in ".{0,64}") {
if let Ok(encoded) = serde_json::to_string(&text) {
let input = format!(r#"{{"jsonrpc":"2.0","id":{encoded},"method":"ping"}}"#);
if let Ok(Frame::Single(envelope)) = decode(input.as_bytes()) {
if let Some(id) = envelope.id() {
let parsed = serde_json::from_str::<serde_json::Value>(&id.to_json());
prop_assert!(parsed.is_ok(), "a rendered id must be valid JSON");
prop_assert_eq!(
parsed.unwrap_or(serde_json::Value::Null),
serde_json::Value::String(text),
);
} else {
prop_assert!(false, "the envelope has an id");
}
} else {
prop_assert!(false, "a request with a valid id must decode");
}
} else {
prop_assert!(false, "a string always serialises");
}
}
#[test]
fn a_method_is_known_or_refused(name in ".{0,64}") {
let known = McpMethodTable::TABLE.lookup(&name).is_some();
if let Ok(encoded) = serde_json::to_string(&name) {
let input = format!(r#"{{"jsonrpc":"2.0","id":1,"method":{encoded}}}"#);
let profile = open();
match route_frame(input.as_bytes(), &McpMethodTable::TABLE, &profile, &AllowBatching) {
Ok(_) => prop_assert!(known, "a method the table lacks must be refused"),
Err(error) => {
prop_assert!(!known, "a method the table has must not be refused");
prop_assert_eq!(error.code().code(), -32601);
}
}
} else {
prop_assert!(false, "a string always serialises");
}
}
#[test]
fn a_batch_is_all_or_nothing(
methods in prop::collection::vec(
prop::sample::select(vec!["ping", "tools/list", "frobnicate", ""]),
1..4,
),
) {
let elements: Vec<String> = methods
.iter()
.map(|method| format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#))
.collect();
let input = format!("[{}]", elements.join(","));
let profile = open();
if let Ok(routed) = route_frame(
input.as_bytes(),
&McpMethodTable::TABLE,
&profile,
&AllowBatching,
) {
prop_assert_eq!(routed.len(), methods.len());
for method in &methods {
prop_assert!(
McpMethodTable::TABLE.lookup(method).is_some(),
"a routed batch contained `{method}`, which is not a method",
);
}
}
}
}