use std::collections::BTreeMap;
use std::fmt;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::value::{RawValue, to_raw_value};
use super::schema::{self, ToolSchema};
pub const SUPPORTED_PROTOCOL_VERSION: &str = "2025-11-25";
const JSON_RPC_VERSION: &str = "2.0";
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct RequestId(Box<RawValue>);
impl RequestId {
pub fn from_raw(raw: Box<RawValue>) -> Result<Self, Refusal> {
let text = raw.get();
if text.starts_with('"') || is_integer_number(text) {
return Ok(Self(raw));
}
Err(Refusal::new(
None,
true,
-32600,
"rpc_invalid_id",
"id must be a string or integer",
))
}
}
fn is_integer_number(text: &str) -> bool {
let text = text.strip_prefix('-').unwrap_or(text);
if !text.as_bytes().first().is_some_and(u8::is_ascii_digit) {
return false;
}
let (mantissa, exponent) = text.split_once(['e', 'E']).unwrap_or((text, "0"));
let zero = mantissa.bytes().all(|byte| byte == b'0' || byte == b'.');
if zero {
return true;
}
let fractional_places = mantissa
.split_once('.')
.map_or(0, |(_, fraction)| fraction.len());
let trailing_zeroes = mantissa
.bytes()
.rev()
.filter(|byte| *byte != b'.')
.take_while(|byte| *byte == b'0')
.count();
let negative_exponent = exponent.starts_with('-');
let magnitude = exponent.trim_start_matches(['+', '-']).parse::<usize>();
match magnitude {
Ok(magnitude) if negative_exponent => fractional_places
.checked_add(magnitude)
.is_some_and(|needed| needed <= trailing_zeroes),
Ok(magnitude) => fractional_places.saturating_sub(magnitude) <= trailing_zeroes,
Err(error) if matches!(error.kind(), std::num::IntErrorKind::PosOverflow) => {
!negative_exponent
}
Err(_) => false,
}
}
#[derive(Debug)]
pub struct Refusal {
id: Option<RequestId>,
respond: bool,
number: i32,
name: &'static str,
message: String,
}
impl Refusal {
fn new(
id: Option<RequestId>,
respond: bool,
number: i32,
name: &'static str,
message: impl Into<String>,
) -> Self {
Self {
id,
respond,
number,
name,
message: message.into(),
}
}
pub fn encode(&self) -> Result<Option<Vec<u8>>, serde_json::Error> {
if !self.respond {
return Ok(None);
}
serde_json::to_vec(&ErrorResponse {
jsonrpc: JSON_RPC_VERSION,
id: self.id.as_ref(),
error: ErrorBody {
code: self.number,
message: &self.message,
data: ErrorData { code: self.name },
},
})
.map(Some)
}
}
impl fmt::Display for Refusal {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.name, self.message)
}
}
impl std::error::Error for Refusal {}
#[derive(Serialize)]
struct ErrorResponse<'a> {
jsonrpc: &'static str,
id: Option<&'a RequestId>,
error: ErrorBody<'a>,
}
#[derive(Serialize)]
struct ErrorBody<'a> {
code: i32,
message: &'a str,
data: ErrorData,
}
#[derive(Serialize)]
struct ErrorData {
code: &'static str,
}
#[derive(Serialize)]
struct Success<'a> {
jsonrpc: &'static str,
id: &'a RequestId,
result: &'a RawValue,
}
pub fn encode_result<T: Serialize + ?Sized>(
id: &RequestId,
result: &T,
) -> Result<Vec<u8>, Refusal> {
let failure = |error: serde_json::Error| {
Refusal::new(
Some(id.clone()),
true,
-32603,
"rpc_result_encoding_failed",
error.to_string(),
)
};
let raw = to_raw_value(result).map_err(failure)?;
if !raw.get().starts_with('{') {
return Err(Refusal::new(
Some(id.clone()),
true,
-32603,
"rpc_result_not_object",
"result must be an object",
));
}
serde_json::to_vec(&Success {
jsonrpc: JSON_RPC_VERSION,
id,
result: &raw,
})
.map_err(failure)
}
fn present_raw<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Box<RawValue>>, D::Error> {
Box::<RawValue>::deserialize(deserializer).map(Some)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Envelope {
#[serde(default, deserialize_with = "present_raw")]
jsonrpc: Option<Box<RawValue>>,
#[serde(default, deserialize_with = "present_raw")]
id: Option<Box<RawValue>>,
#[serde(default, deserialize_with = "present_raw")]
method: Option<Box<RawValue>>,
#[serde(default, deserialize_with = "present_raw")]
params: Option<Box<RawValue>>,
}
#[derive(Debug)]
pub struct Request {
id: Option<RequestId>,
method: Method,
}
impl Request {
#[must_use]
pub const fn id(&self) -> Option<&RequestId> {
self.id.as_ref()
}
#[must_use]
pub const fn method(&self) -> &Method {
&self.method
}
}
#[derive(Debug)]
pub enum Method {
Initialize(InitializeParams),
Initialized,
Ping,
ToolsList,
ToolsCall(ToolCall),
}
type RawObject = BTreeMap<String, Box<RawValue>>;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeParams {
pub protocol_version: String,
pub capabilities: RawObject,
pub client_info: Implementation,
#[serde(rename = "_meta")]
pub metadata: Option<RawObject>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Implementation {
pub name: String,
pub version: String,
pub title: Option<String>,
pub description: Option<String>,
pub icons: Option<Vec<Icon>>,
pub website_url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Icon {
pub src: String,
pub mime_type: Option<String>,
pub sizes: Option<Vec<String>>,
pub theme: Option<String>,
}
#[derive(Deserialize)]
struct EmptyParams {
#[serde(rename = "_meta")]
metadata: Option<RawObject>,
}
#[derive(Deserialize)]
struct ListParams {
cursor: Option<String>,
#[serde(rename = "_meta")]
metadata: Option<RawObject>,
}
#[derive(Deserialize)]
struct CallParams {
name: String,
#[serde(default, deserialize_with = "present_raw")]
arguments: Option<Box<RawValue>>,
#[serde(default, deserialize_with = "present_raw")]
task: Option<Box<RawValue>>,
#[serde(rename = "_meta")]
metadata: Option<RawObject>,
}
#[derive(Debug)]
pub struct ToolCall {
pub name: String,
pub arguments: Box<RawValue>,
pub metadata: Option<RawObject>,
}
#[derive(Debug)]
pub enum Prepared {
NoResponse,
Response(Vec<u8>),
ToolCall { id: RequestId, call: ToolCall },
}
#[derive(Debug)]
pub struct RpcCodec {
tools: [ToolSchema; 11],
}
impl Default for RpcCodec {
fn default() -> Self {
Self::new()
}
}
impl RpcCodec {
#[must_use]
pub fn new() -> Self {
Self::with_registry(schema::tools)
}
fn with_registry(build: impl FnOnce() -> [ToolSchema; 11]) -> Self {
Self { tools: build() }
}
pub fn decode(&self, frame: &[u8], byte_limit: usize) -> Result<Request, Refusal> {
if frame.len() > byte_limit {
return Err(Refusal::new(
None,
true,
-32000,
"rpc_frame_too_large",
"frame exceeds byte limit",
));
}
let raw: Box<RawValue> = serde_json::from_slice(frame).map_err(|error| {
Refusal::new(None, true, -32700, "rpc_parse_error", error.to_string())
})?;
match raw.get().as_bytes().first() {
Some(b'{') => {}
Some(b'[') => {
return Err(Refusal::new(
None,
true,
-32600,
"rpc_batch_unsupported",
"batch requests are unsupported",
));
}
_ => {
return Err(Refusal::new(
None,
true,
-32600,
"rpc_not_object",
"request must be an object",
));
}
}
let envelope: Envelope = serde_json::from_str(raw.get()).map_err(|error| {
Refusal::new(None, true, -32600, "rpc_invalid_request", error.to_string())
})?;
self.decode_envelope(envelope)
}
fn decode_envelope(&self, envelope: Envelope) -> Result<Request, Refusal> {
let id = envelope.id.map(RequestId::from_raw).transpose()?;
let respond = id.is_some();
let invalid = |message: String| {
Refusal::new(id.clone(), true, -32600, "rpc_invalid_request", message)
};
let version = envelope
.jsonrpc
.as_ref()
.ok_or_else(|| invalid("jsonrpc is required".to_owned()))?;
let version: String =
serde_json::from_str(version.get()).map_err(|error| invalid(error.to_string()))?;
if version != JSON_RPC_VERSION {
return Err(invalid("jsonrpc must be 2.0".to_owned()));
}
let method = envelope
.method
.as_ref()
.ok_or_else(|| invalid("method is required".to_owned()))?;
let method: String =
serde_json::from_str(method.get()).map_err(|error| invalid(error.to_string()))?;
let params = envelope.params.as_deref();
let invalid_params = |message: String| {
Refusal::new(id.clone(), respond, -32602, "rpc_invalid_params", message)
};
if params.is_some_and(|raw| !raw.get().starts_with('{')) {
return Err(invalid_params("params must be an object".to_owned()));
}
let decoded = match method.as_str() {
"initialize" => Method::Initialize(read_initialize(params, id.as_ref())?),
"notifications/initialized" => {
if id.is_some() {
return Err(invalid(
"notifications/initialized must not have an id".to_owned(),
));
}
let input: EmptyParams = read_params(params).map_err(invalid_params)?;
drop(input.metadata);
Method::Initialized
}
"ping" => {
let input: EmptyParams = read_params(params).map_err(invalid_params)?;
drop(input.metadata);
Method::Ping
}
"tools/list" => {
let input: ListParams = read_params(params).map_err(invalid_params)?;
if input.cursor.is_some() {
return Err(Refusal::new(
id,
respond,
-32602,
"mcp_cursor_unsupported",
"the fixed tool list has no continuation cursor",
));
}
drop(input.metadata);
Method::ToolsList
}
"tools/call" => Method::ToolsCall(self.read_call(params, id.as_ref())?),
_ => {
return Err(Refusal::new(
id,
respond,
-32601,
"rpc_unknown_method",
format!("unknown method {method}"),
));
}
};
Ok(Request {
id,
method: decoded,
})
}
fn read_call(
&self,
params: Option<&RawValue>,
id: Option<&RequestId>,
) -> Result<ToolCall, Refusal> {
let input: CallParams =
read_params(params).map_err(|message| parameter_refusal(id, message))?;
if input.task.is_some() {
return Err(Refusal::new(
id.cloned(),
id.is_some(),
-32602,
"mcp_tasks_unsupported",
"task execution is unsupported",
));
}
if !self.tools.iter().any(|tool| tool.name == input.name) {
return Err(Refusal::new(
id.cloned(),
id.is_some(),
-32602,
"mcp_tool_unknown",
format!("unknown tool {}", input.name),
));
}
let arguments = match input.arguments {
Some(arguments) => arguments,
None => RawValue::from_string("{}".to_owned())
.map_err(|error| parameter_refusal(id, error.to_string()))?,
};
if !arguments.get().starts_with('{') {
return Err(parameter_refusal(id, "arguments must be an object"));
}
Ok(ToolCall {
name: input.name,
arguments,
metadata: input.metadata,
})
}
pub fn prepare(&self, request: Request) -> Result<Prepared, Refusal> {
let Some(id) = request.id else {
return Ok(Prepared::NoResponse);
};
let bytes = match request.method {
Method::Initialize(_) => encode_result(
&id,
&InitializeResult {
protocol_version: SUPPORTED_PROTOCOL_VERSION,
capabilities: Capabilities {
tools: ToolsCapability {
list_changed: false,
},
},
server_info: ServerInfo {
name: "haem",
version: env!("CARGO_PKG_VERSION"),
},
},
)?,
Method::Ping => encode_result(&id, &EmptyResult {})?,
Method::ToolsList => encode_result(&id, &ToolsListResult { tools: &self.tools })?,
Method::ToolsCall(call) => return Ok(Prepared::ToolCall { id, call }),
Method::Initialized => {
return Err(Refusal::new(
Some(id),
true,
-32600,
"rpc_invalid_request",
"notifications/initialized must not have an id",
));
}
};
Ok(Prepared::Response(bytes))
}
}
fn read_initialize(
params: Option<&RawValue>,
id: Option<&RequestId>,
) -> Result<InitializeParams, Refusal> {
if id.is_none() {
return Err(Refusal::new(
None,
true,
-32600,
"rpc_invalid_request",
"initialize requires an id",
));
}
let input: InitializeParams =
read_params(params).map_err(|message| parameter_refusal(id, message))?;
if input.protocol_version.is_empty() {
return Err(parameter_refusal(
id,
"protocolVersion must be a nonempty string",
));
}
if input.client_info.name.is_empty() || input.client_info.version.is_empty() {
return Err(parameter_refusal(
id,
"clientInfo name and version must be nonempty",
));
}
Ok(input)
}
fn parameter_refusal(id: Option<&RequestId>, message: impl Into<String>) -> Refusal {
Refusal::new(
id.cloned(),
id.is_some(),
-32602,
"rpc_invalid_params",
message,
)
}
fn read_params<T: DeserializeOwned>(params: Option<&RawValue>) -> Result<T, String> {
serde_json::from_str(params.map_or("{}", RawValue::get)).map_err(|error| error.to_string())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InitializeResult {
protocol_version: &'static str,
capabilities: Capabilities,
server_info: ServerInfo,
}
#[derive(Serialize)]
struct Capabilities {
tools: ToolsCapability,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ToolsCapability {
list_changed: bool,
}
#[derive(Serialize)]
struct ServerInfo {
name: &'static str,
version: &'static str,
}
#[derive(Serialize)]
struct EmptyResult {}
#[derive(Serialize)]
struct ToolsListResult<'a> {
tools: &'a [ToolSchema; 11],
}
#[cfg(test)]
mod tests {
use std::error::Error;
use serde_json::value::RawValue;
use serde_json::{Value, json};
use super::{
Method, Prepared, Refusal, RequestId, RpcCodec, SUPPORTED_PROTOCOL_VERSION, encode_result,
};
type TestResult = Result<(), Box<dyn Error>>;
#[derive(serde::Deserialize)]
struct IdAnswer {
id: Box<RawValue>,
}
#[derive(serde::Deserialize)]
struct ResultAnswer {
result: Box<RawValue>,
}
fn refusal(frame: &[u8], limit: usize) -> Result<Refusal, Box<dyn Error>> {
RpcCodec::new()
.decode(frame, limit)
.err()
.ok_or_else(|| "frame accepted".into())
}
fn error_name(error: &Refusal) -> Result<String, Box<dyn Error>> {
let bytes = error.encode()?.ok_or("refusal had no answer")?;
let body: Value = serde_json::from_slice(&bytes)?;
let name = body
.get("error")
.and_then(|v| v.get("data"))
.and_then(|v| v.get("code"))
.and_then(Value::as_str)
.ok_or("refusal has no code name")?;
Ok(name.to_owned())
}
fn response(codec: &RpcCodec, frame: &[u8]) -> Result<Vec<u8>, Box<dyn Error>> {
match codec.prepare(codec.decode(frame, frame.len())?)? {
Prepared::Response(bytes) => Ok(bytes),
_ => Err("request did not return a response".into()),
}
}
#[test]
fn malformed_json_is_a_named_parse_error() -> TestResult {
let error = refusal(br"{", 4096)?;
assert_eq!(error_name(&error)?, "rpc_parse_error");
Ok(())
}
#[test]
fn nonobjects_and_batch_arrays_are_refused_by_name() -> TestResult {
for text in [r#""text""#, "null", "1", "true"] {
assert_eq!(
error_name(&refusal(text.as_bytes(), 4096)?)?,
"rpc_not_object"
);
}
assert_eq!(error_name(&refusal(b"[]", 4096)?)?, "rpc_batch_unsupported");
Ok(())
}
#[test]
fn unknown_method_keeps_the_request_id() -> TestResult {
let error = refusal(br#"{"jsonrpc":"2.0","id":"a","method":"unknown"}"#, 4096)?;
assert_eq!(error_name(&error)?, "rpc_unknown_method");
let bytes = error.encode()?.ok_or("missing error")?;
let text = String::from_utf8(bytes)?;
assert!(text.contains(r#""id":"a""#));
Ok(())
}
#[test]
fn null_and_noninteger_ids_are_refused() -> TestResult {
for id in ["null", "true", "{}", "[]", "1.5", "1e-2", "1.2e0"] {
let text = format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"ping"}}"#);
assert_eq!(
error_name(&refusal(text.as_bytes(), 4096)?)?,
"rpc_invalid_id"
);
}
Ok(())
}
#[test]
fn number_string_and_large_ids_keep_their_exact_spelling() -> TestResult {
let codec = RpcCodec::new();
for spelling in [
"1",
"-0",
"1e2",
"1.0",
"1.2e1",
"18446744073709551616001",
r#""a\u0062""#,
] {
let text = format!(r#"{{"jsonrpc":"2.0","id":{spelling},"method":"ping"}}"#);
let bytes = response(&codec, text.as_bytes())?;
let answer: IdAnswer = serde_json::from_slice(&bytes)?;
assert_eq!(answer.id.get(), spelling);
}
Ok(())
}
#[test]
fn notifications_return_no_answer_even_when_the_method_is_unknown() -> TestResult {
let codec = RpcCodec::new();
for method in ["notifications/initialized", "ping", "tools/list"] {
let text = format!(r#"{{"jsonrpc":"2.0","method":"{method}"}}"#);
assert!(matches!(
codec.prepare(codec.decode(text.as_bytes(), 4096)?)?,
Prepared::NoResponse
));
}
let error = refusal(br#"{"jsonrpc":"2.0","method":"unknown"}"#, 4096)?;
assert!(error.encode()?.is_none());
Ok(())
}
fn initialize(version: &str) -> String {
format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"{version}","capabilities":{{}},"clientInfo":{{"name":"test","version":"1"}}}}}}"#
)
}
#[test]
fn initialize_refuses_an_empty_version_by_name() -> TestResult {
let text = initialize("");
assert_eq!(
error_name(&refusal(text.as_bytes(), 4096)?)?,
"rpc_invalid_params"
);
Ok(())
}
#[test]
fn initialize_names_one_version_and_only_static_tools() -> TestResult {
let codec = RpcCodec::new();
let bytes = response(&codec, initialize(SUPPORTED_PROTOCOL_VERSION).as_bytes())?;
let answer: Value = serde_json::from_slice(&bytes)?;
let result = answer.get("result").ok_or("no result")?;
assert_eq!(
result.get("protocolVersion"),
Some(&json!(SUPPORTED_PROTOCOL_VERSION))
);
let capabilities = result
.get("capabilities")
.and_then(Value::as_object)
.ok_or("no capabilities")?;
assert_eq!(capabilities.len(), 1);
assert_eq!(
capabilities.get("tools"),
Some(&json!({"listChanged":false}))
);
assert_eq!(
result.get("serverInfo").and_then(|v| v.get("name")),
Some(&json!("haem"))
);
Ok(())
}
#[test]
fn ping_returns_an_empty_object() -> TestResult {
let codec = RpcCodec::new();
let answer: Value = serde_json::from_slice(&response(
&codec,
br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#,
)?)?;
assert_eq!(answer.get("result"), Some(&json!({})));
Ok(())
}
#[test]
fn tools_list_has_eleven_pairs_and_reuses_the_held_registry() -> TestResult {
let builds = std::cell::Cell::new(0);
let codec = RpcCodec::with_registry(|| {
builds.set(builds.get() + 1);
crate::mcp::schema::tools()
});
let held = codec.tools.as_ptr();
assert_eq!(builds.get(), 1);
for _ in 0..2 {
let answer: Value = serde_json::from_slice(&response(
&codec,
br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
)?)?;
let tools = answer
.get("result")
.and_then(|v| v.get("tools"))
.and_then(Value::as_array)
.ok_or("no tools")?;
let names = tools
.iter()
.map(|tool| {
tool.get("name")
.and_then(Value::as_str)
.ok_or("missing name")
})
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(
names,
[
"describe_store",
"run_query",
"read_history",
"define_collection",
"put_records",
"delete_record",
"link_records",
"unlink_records",
"create_branch",
"review_branch",
"merge_branch"
]
);
for tool in tools {
assert!(tool.get("inputSchema").is_some_and(Value::is_object));
assert!(tool.get("outputSchema").is_some_and(Value::is_object));
}
assert_eq!(codec.tools.as_ptr(), held);
assert_eq!(builds.get(), 1);
}
Ok(())
}
#[test]
fn an_unknown_tool_is_refused_without_dispatch() -> TestResult {
let error = refusal(br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"missing","arguments":{}}}"#, 4096)?;
assert_eq!(error_name(&error)?, "mcp_tool_unknown");
Ok(())
}
#[test]
fn tool_call_stops_at_a_typed_name_and_raw_arguments() -> TestResult {
let codec = RpcCodec::new();
let body = r#"{ "collection":"records", "n":1e2 }"#;
let text = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"put_records","arguments":{body}}}}}"#
);
let request = codec.decode(text.as_bytes(), 4096)?;
assert!(matches!(&request.method, Method::ToolsCall(_)));
match codec.prepare(request)? {
Prepared::ToolCall { call, .. } => {
assert_eq!(call.name, "put_records");
assert_eq!(call.arguments.get(), body);
}
_ => return Err("tool call was answered or discarded".into()),
}
Ok(())
}
#[test]
fn a_frame_one_byte_over_limit_is_refused_before_parsing() -> TestResult {
let malformed = br#"{"jsonrpc":"#;
assert_eq!(
error_name(&refusal(malformed, malformed.len() - 1)?)?,
"rpc_frame_too_large"
);
assert_eq!(
error_name(&refusal(malformed, malformed.len())?)?,
"rpc_parse_error"
);
Ok(())
}
#[test]
fn result_body_numbers_leave_with_their_original_spelling() -> TestResult {
let id = RequestId::from_raw(RawValue::from_string(r#""r1""#.to_owned())?)?;
let body =
RawValue::from_string(r#"{ "n":1e2, "big":18446744073709551616001 }"#.to_owned())?;
let bytes = encode_result(&id, body.as_ref())?;
let answer: ResultAnswer = serde_json::from_slice(&bytes)?;
assert_eq!(answer.result.get(), body.get());
Ok(())
}
#[test]
fn invalid_params_and_wrong_rpc_version_are_named() -> TestResult {
for text in [
r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":null}"#,
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"put_records","arguments":[]}}"#,
r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{"_meta":[]}}"#,
] {
assert_eq!(
error_name(&refusal(text.as_bytes(), 4096)?)?,
"rpc_invalid_params"
);
}
assert_eq!(
error_name(&refusal(
br#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#,
4096
)?)?,
"rpc_invalid_request"
);
Ok(())
}
#[test]
fn older_protocol_versions_negotiate_the_supported_version() -> TestResult {
let codec = RpcCodec::new();
for version in ["2025-06-18", "2024-11-05"] {
let answer: Value =
serde_json::from_slice(&response(&codec, initialize(version).as_bytes())?)?;
assert_eq!(
answer
.get("result")
.and_then(|value| value.get("protocolVersion")),
Some(&json!(SUPPORTED_PROTOCOL_VERSION)),
);
}
Ok(())
}
#[test]
fn initialization_ignores_unconsumed_protocol_members() -> TestResult {
let codec = RpcCodec::new();
let text = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"{SUPPORTED_PROTOCOL_VERSION}","capabilities":{{"future":{{"enabled":true}}}},"future":true,"clientInfo":{{"name":"test","version":"1","future":true,"icons":[{{"src":"https://example.com/icon.png","future":true}}]}}}}}}"#
);
let answer: Value = serde_json::from_slice(&response(&codec, text.as_bytes())?)?;
assert_eq!(
answer
.get("result")
.and_then(|value| value.get("protocolVersion")),
Some(&json!(SUPPORTED_PROTOCOL_VERSION)),
);
Ok(())
}
#[test]
fn tools_list_ignores_unconsumed_protocol_params() -> TestResult {
let codec = RpcCodec::new();
let frame = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"future":true}}"#;
let answer: Value = serde_json::from_slice(&response(&codec, frame)?)?;
let tools = answer
.get("result")
.and_then(|value| value.get("tools"))
.and_then(Value::as_array)
.ok_or("tools absent")?;
assert_eq!(tools.len(), 11);
Ok(())
}
#[test]
fn tools_call_ignores_unconsumed_protocol_members() -> TestResult {
let codec = RpcCodec::new();
let frame = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"put_records","arguments":{},"future":{"n":1e2}}}"#;
match codec.prepare(codec.decode(frame, 4096)?)? {
Prepared::ToolCall { call, .. } => {
assert_eq!(call.name, "put_records");
assert_eq!(call.arguments.get(), "{}");
}
_ => return Err("tools/call was not decoded".into()),
}
Ok(())
}
#[test]
fn any_present_task_is_refused_by_name_without_dispatch() -> TestResult {
for task in ["{}", "null", "true", r#"{"ttl":1000}"#] {
let frame = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"put_records","arguments":{{}},"task":{task}}}}}"#
);
assert_eq!(
error_name(&refusal(frame.as_bytes(), 4096)?)?,
"mcp_tasks_unsupported"
);
}
Ok(())
}
#[test]
fn tool_argument_members_remain_strict_after_protocol_decoding() -> TestResult {
use crate::mcp::types::PutRecordsInput;
let codec = RpcCodec::new();
let arguments = r#"{"request_id":"r1","branch":"feature","expected":{"generation":"1","sequence":"2"},"collection":"records","records":[{"document_id":"a","document":{"n":1e2}}],"future":true}"#;
let frame = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"put_records","arguments":{arguments},"future":true}}}}"#
);
let Prepared::ToolCall { call, .. } =
codec.prepare(codec.decode(frame.as_bytes(), 4096)?)?
else {
return Err("tools/call was not decoded".into());
};
assert_eq!(call.arguments.get(), arguments);
let error = serde_json::from_str::<PutRecordsInput>(call.arguments.get())
.err()
.ok_or("unknown tool argument was accepted")?;
assert!(error.to_string().contains("unknown field"), "{error}");
Ok(())
}
#[test]
fn ping_and_initialized_ignore_unconsumed_protocol_params() -> TestResult {
let codec = RpcCodec::new();
let ping = br#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{"future":true}}"#;
let answer: Value = serde_json::from_slice(&response(&codec, ping)?)?;
assert_eq!(answer.get("result"), Some(&json!({})));
let initialized =
br#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{"future":true}}"#;
assert!(matches!(
codec.prepare(codec.decode(initialized, 4096)?)?,
Prepared::NoResponse
));
Ok(())
}
#[test]
fn envelope_members_remain_strict() -> TestResult {
let frame = br#"{"jsonrpc":"2.0","id":1,"method":"ping","future":true}"#;
assert_eq!(error_name(&refusal(frame, 4096)?)?, "rpc_invalid_request");
Ok(())
}
}