When any crate in a downstream build enables serde_json/arbitrary_precision, Cargo feature unification turns it on for rmcp as well, and every f32/f64 field that rmcp reads through serde's buffered path fails on a decimal with invalid type: map, expected f32. Integers still parse, so only fractional values are affected. Because ServerResult and ServerNotification are untagged unions ending in a catch-all, nothing reports the error: a tools/call result silently becomes CustomResult and a fractional notifications/progress becomes CustomNotification.
This is serde's known limitation with that feature (serde-rs/json#721, serde-rs/serde#1183): serde replays a buffered decimal as serde_json's private number map, which a plain float field cannot read. rmcp cannot keep the feature out of a downstream build, and the case is real: the OpenAI Codex CLI gets it from starlark 0.14.2 (unconditionally) and from one of its own crates, and as a result every tool call to a server that sets a fractional annotations.priority fails there (openai/codex#38979).
Reproduction
[dependencies]
rmcp = { version = "=3.4.1", default-features = false, features = ["client"] }
serde_json = "1"
[features]
ap = ["serde_json/arbitrary_precision"]
use rmcp::model::{JsonRpcMessage, ServerResult};
use rmcp::service::{RoleClient, RxJsonRpcMessage};
let line = r#"{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"ok","annotations":{"priority":0.6}}]}}"#;
let msg: RxJsonRpcMessage<RoleClient> = serde_json::from_str(line).unwrap();
let JsonRpcMessage::Response(resp) = msg else { unreachable!() };
println!("{}", matches!(resp.result, ServerResult::CallToolResult(_)));
| Input |
without ap |
with ap |
tools/call result, priority: 0.6 |
CallToolResult |
CustomResult |
tools/call result, priority: 1.0 |
CallToolResult |
CustomResult |
tools/call result, priority: 1 |
CallToolResult |
CallToolResult |
CallToolResult parsed directly, priority: 0.6 |
Ok |
Err(invalid type: map, expected f32) |
notifications/progress, progress: 0.5 |
ProgressNotification |
CustomNotification |
The same happens on 3.2.0. It also reaches rmcp talking to itself: an f64 progress of 1 serializes as 1.0, so in such a build an rmcp client drops the progress notifications an rmcp server sends. Running the crate's own tests with the feature on, test_request_timeout_progress (two tests) and stateless_server_first_request_handler_can_send_notifications fail on main for that reason.
Affected fields (main at 6e47ca3)
Annotations.priority (model/annotated.rs)
ProgressNotificationParam.progress, total (model.rs)
CreateMessageRequestParams.temperature (model.rs)
ModelPreferences.cost_priority, speed_priority, intelligence_priority (model.rs)
NumberSchema.minimum, maximum, default (model/elicitation_schema.rs)
Proposal
Deserialize these fields through serde_json::Number, which understands both representations, with pub(crate) helpers in model/serde_impl.rs and #[serde(deserialize_with = ...)] (plus default on the Option fields). Serialization, the generated schemas and the public API do not change, and without the feature the decoded values are the same as today, since serde_json already reads an f32 field as an f64 and casts it. With the feature on, every row above decodes as CallToolResult or ProgressNotification, including spellings such as 0.60 and 6e-1, 0.6 decodes to exactly 0.6f32, and a string priority is still rejected.
I am opening a pull request with this change right after this issue, covering all ten fields. The regression tests need a build where serde_json has the feature, so it enables it for rmcp's tests through a dev-dependency; the pull request describes the trade-off and the alternative (a small crate outside the workspace with a CI step of its own), and I am happy to switch if you prefer that.
When any crate in a downstream build enables
serde_json/arbitrary_precision, Cargo feature unification turns it on for rmcp as well, and everyf32/f64field that rmcp reads through serde's buffered path fails on a decimal withinvalid type: map, expected f32. Integers still parse, so only fractional values are affected. BecauseServerResultandServerNotificationare untagged unions ending in a catch-all, nothing reports the error: atools/callresult silently becomesCustomResultand a fractionalnotifications/progressbecomesCustomNotification.This is serde's known limitation with that feature (serde-rs/json#721, serde-rs/serde#1183): serde replays a buffered decimal as serde_json's private number map, which a plain float field cannot read. rmcp cannot keep the feature out of a downstream build, and the case is real: the OpenAI Codex CLI gets it from
starlark 0.14.2(unconditionally) and from one of its own crates, and as a result every tool call to a server that sets a fractionalannotations.priorityfails there (openai/codex#38979).Reproduction
apaptools/callresult,priority: 0.6CallToolResultCustomResulttools/callresult,priority: 1.0CallToolResultCustomResulttools/callresult,priority: 1CallToolResultCallToolResultCallToolResultparsed directly,priority: 0.6OkErr(invalid type: map, expected f32)notifications/progress,progress: 0.5ProgressNotificationCustomNotificationThe same happens on 3.2.0. It also reaches rmcp talking to itself: an
f64progress of1serializes as1.0, so in such a build an rmcp client drops the progress notifications an rmcp server sends. Running the crate's own tests with the feature on,test_request_timeout_progress(two tests) andstateless_server_first_request_handler_can_send_notificationsfail on main for that reason.Affected fields (main at 6e47ca3)
Annotations.priority(model/annotated.rs)ProgressNotificationParam.progress,total(model.rs)CreateMessageRequestParams.temperature(model.rs)ModelPreferences.cost_priority,speed_priority,intelligence_priority(model.rs)NumberSchema.minimum,maximum,default(model/elicitation_schema.rs)Proposal
Deserialize these fields through
serde_json::Number, which understands both representations, withpub(crate)helpers inmodel/serde_impl.rsand#[serde(deserialize_with = ...)](plusdefaulton theOptionfields). Serialization, the generated schemas and the public API do not change, and without the feature the decoded values are the same as today, since serde_json already reads anf32field as anf64and casts it. With the feature on, every row above decodes asCallToolResultorProgressNotification, including spellings such as0.60and6e-1,0.6decodes to exactly0.6f32, and a string priority is still rejected.I am opening a pull request with this change right after this issue, covering all ten fields. The regression tests need a build where serde_json has the feature, so it enables it for rmcp's tests through a dev-dependency; the pull request describes the trade-off and the alternative (a small crate outside the workspace with a CI step of its own), and I am happy to switch if you prefer that.