Repository navigation
fix(model): reject malformed JSON-RPC messages with conflicting result and error fields (#1283) - #1287
Conversation
Status re-check (Asia/Shanghai 2026-09-22)Re-verified fork head
No further code changes in this update — only confirming the PR is still merge-ready relative to Related: #1283
|
jarrettdustinqq
left a comment
There was a problem hiding this comment.
I reproduced a borrowed-payload compatibility regression in 6b9f15ec301fdef27559e6b1d8c4f1aa443adfec, relative to base dbd238275534c3a8da4d91b7220655e878216988.
The comment above the new deserializer says the Deserialize<'de> bounds preserve the previous API. They preserve the type-level bound, but collecting the input into an owned serde_json::Value loses the input borrows before variant deserialization. Valid generic request and response types containing &str now fail at runtime.
I ran an identical external test harness against each unmodified SDK snapshot, using the SDK as a path dependency with default-features = false:
| Case | Base | PR head |
|---|---|---|
Borrowed request method parsed from &str |
Pass | Fail: expected a borrowed string |
Borrowed response field parsed from &str |
Pass | Fail: expected a borrowed string |
| Owned request control | Pass | Pass |
| Owned response control | Pass | Pass |
The passing borrowed cases also check that the returned string points inside the original input. Toolchain: rustc 1.96.0 (ac68faa20 2026-05-25), cargo 1.96.0 (30a34c682 2026-05-25); serde 1.0.229, serde_json 1.0.151. These are four focused external tests, not a rerun of the full SDK or conformance suite.
This request case, copied from the executed harness, reproduces the failure:
use rmcp::model::JsonRpcMessage;
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Deserialize)]
struct BorrowedRequest<'a> {
method: &'a str,
}
#[test]
fn generic_borrowed_request_deserializes_from_str() {
let input = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
let parsed = serde_json::from_str::<JsonRpcMessage<BorrowedRequest<'_>, Value, Value>>(input)
.expect("previous generic Deserialize API accepts borrowed request strings");
match parsed {
JsonRpcMessage::Request(request) => {
assert_eq!(request.request.method, "ping");
let start = input.as_ptr() as usize;
let borrowed = request.request.method.as_ptr() as usize;
assert!(borrowed >= start && borrowed < start + input.len());
}
other => panic!("expected Request, received {other:?}"),
}
}Could the parser preserve borrowed payload deserialization while rejecting conflicting fields, with this regression case retained? If borrowed generic payloads are intentionally unsupported, that would instead need an explicit compatibility decision and correction of the current preservation claim. I have not established a downstream application relying on this behavior.
AI disclosure: an authorized AI assistant performed the source review, prepared and executed the local regression harness, and drafted this comment. This does not claim the account owner's unaided Rust implementation or testing.
6b9f15e to
e92906f
Compare
Keep the public Deserialize impl equivalent to the previous derive (Deserialize<'de> bounds, Noti type param) so cargo public-api --deny removed passes, while still rejecting conflicting result/error fields.
Reject conflicting result/error shapes without forcing an owned serde_json::Value intermediate for from_str/from_slice. Use serde_json's RawValue newtype token so Deserialize<'de> payload types can keep borrows into the original input, with an owned fallback for from_value. Adds regression coverage for generic borrowed request/response payloads.
Factor the PhantomData<fn() -> (Req, Resp, Noti)> marker into a type alias so clippy::type_complexity no longer fails CI under -D warnings.
e92906f to
29e685d
Compare
CI repair (Asia/Shanghai 2026-09-28)Rebased onto current
Change: introduce AI assistance disclosure: This comment and the Clippy repair commit were prepared with AI assistance (Grok Bot); I reviewed the change and test results before pushing. |
| match key.as_str() { | ||
| "method" => method = true, | ||
| "id" => id = true, | ||
| "result" => result = true, | ||
| "error" => error = true, | ||
| _ => {} | ||
| } | ||
| map.next_value::<serde::de::IgnoredAny>()?; |
There was a problem hiding this comment.
This also now rejects "result": {...}, "error": null, which some lenient servers, especially those using JSON-RPC 1.0, return and which used to work. Should we treat a null result or error as absent so we only reject messages with a genuine conflict?
| fn deserialize<__D>(deserializer: __D) -> Result<Self, __D::Error> | ||
| where | ||
| __D: serde::Deserializer<'de>, |
There was a problem hiding this comment.
A plain D matches the other implementations in this PR.
| fn deserialize<__D>(deserializer: __D) -> Result<Self, __D::Error> | |
| where | |
| __D: serde::Deserializer<'de>, | |
| fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | |
| where | |
| D: serde::Deserializer<'de>, |
DaleSeo
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround, @ump45nose! This looks good now. I only have one extra comment.
A JSON-RPC id of 1.0 or 1e2 is an integer written the way some encoders write every number. It used to be rejected, so the request got no response. NumberOrString now accepts a float that an f64 represents exactly as an integer (up to 2^53) and reads it as that integer. modelcontextprotocol#1287 already makes a message with an id and a method parse only as a request, so the earlier change to JsonRpcNotification is dropped; the tests for an unusable id stay as regression tests. Refs modelcontextprotocol#1345 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1346) * fix(model): read a request id written in float form as that integer A JSON-RPC id of 1.0 or 1e2 is an integer written the way some encoders write every number. It used to be rejected, so the request got no response. NumberOrString now accepts a float that an f64 represents exactly as an integer (up to 2^53) and reads it as that integer. #1287 already makes a message with an id and a method parse only as a request, so the earlier change to JsonRpcNotification is dropped; the tests for an unusable id stay as regression tests. Refs #1345 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(model): reject 2^53 as a float-form id; it cannot say which integer was sent 2^53 + 1 written in float form parses to the same f64 as 2^53, so accepting 2^53 silently turned 9007199254740993.0 into 9007199254740992. The exact-integer range is now strictly below 2^53. Added that id to request_with_unusable_id_is_rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
fix(model): reject malformed JSON-RPC messages with conflicting result and error fields (#1283)
Verification
The following repository checks were rerun after the six-line redundant-error-arm cleanup in
beee740(2026-10-14). The cleanup preserves accepted/rejected shapes and the existing buffering, borrowing, and null behavior.Using Rust/Cargo 1.96.0:
beee740; the repository tests above were. It checks borrowed-string pointers, direct/single/batch decoding, strict conflicting-field rejection, null results, and six borrowed/boxed RawValue decoding routes.These are local verification results. The full workspace integration and conformance suites were not rerun locally; remote checks must be read for the current commit rather than the previous head.
Related to #1283