Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ impl<'de> Deserialize<'de> for NumberOrString {
} else {
Err(serde::de::Error::custom("Number too large for i64"))
}
} else if let Some(i) = n.as_f64().and_then(integral_f64_to_i64) {
// `1.0` and `1e2` are integers written in float form
Ok(NumberOrString::Number(i))
} else {
Err(serde::de::Error::custom("Expected an integer"))
}
Expand All @@ -383,6 +386,13 @@ impl<'de> Deserialize<'de> for NumberOrString {
}
}

/// The integer a JSON number in float form stands for, when it is one that an
/// `f64` represents exactly.
fn integral_f64_to_i64(f: f64) -> Option<i64> {
const MAX_EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
(f.fract() == 0.0 && f.abs() <= MAX_EXACT).then_some(f as i64)
}

#[cfg(feature = "schemars")]
impl schemars::JsonSchema for NumberOrString {
fn schema_name() -> Cow<'static, str> {
Expand Down Expand Up @@ -599,7 +609,7 @@ impl JsonRpcError {
}
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[derive(Debug, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct JsonRpcNotification<N = Notification> {
Expand All @@ -608,6 +618,40 @@ pub struct JsonRpcNotification<N = Notification> {
pub notification: N,
}

impl<'de, N> Deserialize<'de> for JsonRpcNotification<N>
where
N: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Repr<N> {
jsonrpc: JsonRpcVersion2_0,
#[serde(default)]
id: Option<Value>,
#[serde(flatten)]
notification: N,
}

let repr = Repr::<N>::deserialize(deserializer)?;
// A notification has no `id`. A message that carries one is a request,
// and it gets here only because its `id` did not parse as a `RequestId`.
// Reading it as a notification would leave the peer waiting for a
// response that never comes, so it is an invalid message instead.
if repr.id.is_some() {
return Err(serde::de::Error::custom(
"a notification must not have an `id`",
));
}
Ok(Self {
jsonrpc: repr.jsonrpc,
notification: repr.notification,
})
}
}

/// Standard JSON-RPC error codes used throughout the MCP protocol.
///
/// These codes follow the JSON-RPC 2.0 specification and provide
Expand Down
45 changes: 45 additions & 0 deletions crates/rmcp/src/transport/async_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -776,4 +776,49 @@ mod test {
}),
);
}

#[cfg(feature = "server")]
#[tokio::test]
async fn receive_rejects_request_with_unusable_id() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

use crate::{RoleServer, transport::Transport};

let (server_io, client_io) = tokio::io::duplex(4096);
let (server_r, server_w) = tokio::io::split(server_io);
let (client_r, mut client_w) = tokio::io::split(client_io);

let mut transport = AsyncRwTransport::<RoleServer, _, _>::new(server_r, server_w);

// A request whose `id` is not an integer or a string, followed by a valid
// notification. The request must be answered with an error, not read as a
// notification and dropped, or the peer waits for a response forever.
client_w
.write_all(
b"{\"jsonrpc\":\"2.0\",\"id\":1.5,\"method\":\"tools/list\"}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n",
)
.await
.unwrap();

let received = transport
.receive()
.await
.expect("transport should reject the request and yield the next valid message");
assert_eq!(
serde_json::to_value(&received).unwrap()["method"],
"notifications/initialized",
);

let mut reply_buf = Vec::new();
let mut peer = BufReader::new(client_r);
peer.read_until(b'\n', &mut reply_buf).await.unwrap();
let reply: serde_json::Value = serde_json::from_slice(&reply_buf).unwrap();
assert_eq!(
reply,
serde_json::json!({
"jsonrpc": "2.0",
"error": {"code": -32600, "message": "Invalid request"},
}),
);
}
}
59 changes: 59 additions & 0 deletions crates/rmcp/tests/test_deserialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,62 @@ mod arbitrary_precision {
assert_eq!(amount.default, Some(1.5));
}
}

/// A message that carries `id` and `method` is a request. When the `id` is not
/// one the SDK can represent, the message is invalid; it must not be read as a
/// notification, which would leave the peer waiting for a response.
mod request_id {
use rmcp::model::{ClientJsonRpcMessage, JsonRpcMessage, NumberOrString};

fn parse(id: &str, method: &str) -> Result<ClientJsonRpcMessage, serde_json::Error> {
serde_json::from_str(&format!(
r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}"}}"#
))
}

#[test]
fn request_with_unusable_id_is_rejected() {
for id in ["9223372036854775808", "1.5", "true", "[1]", "{}"] {
for method in ["ping", "tools/list", "notifications/initialized"] {
let result = parse(id, method);
assert!(
result.is_err(),
"id {id} with method {method} parsed as {result:?}"
);
}
}
}

#[test]
fn integral_id_in_float_form_is_that_integer() {
for (id, expected) in [("1.0", 1), ("1e2", 100), ("-3.0", -3)] {
let JsonRpcMessage::Request(request) = parse(id, "ping").unwrap() else {
panic!("id {id} did not parse as a request");
};
assert_eq!(request.id, NumberOrString::Number(expected), "id {id}");
}
}

#[test]
fn largest_i64_id_is_kept() {
let JsonRpcMessage::Request(request) = parse("9223372036854775807", "ping").unwrap() else {
panic!("expected a request");
};
assert_eq!(request.id, NumberOrString::Number(i64::MAX));
}

#[test]
fn notification_still_parses() {
let message: ClientJsonRpcMessage =
serde_json::from_str(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
.unwrap();
assert!(matches!(message, JsonRpcMessage::Notification(_)));

// `"id": null` is how some peers spell "no id"; it stays a notification.
let message: ClientJsonRpcMessage = serde_json::from_str(
r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized"}"#,
)
.unwrap();
assert!(matches!(message, JsonRpcMessage::Notification(_)));
}
}
Loading