Skip to content

fix(model): reject malformed JSON-RPC messages with conflicting result and error fields (#1283) - #1287

Merged
DaleSeo merged 8 commits into
modelcontextprotocol:mainfrom
ump45nose:contrib/193343ca37c2
Oct 14, 2026
Merged

DaleSeo merged 8 commits into
modelcontextprotocol:mainfrom
ump45nose:contrib/193343ca37c2

Conversation

@ump45nose

@ump45nose ump45nose commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

fix(model): reject malformed JSON-RPC messages with conflicting result and error fields (#1283)

  • Use one borrowing-compatible buffered deserializer for direct messages and single/batch outer untagged wrappers.
  • Remove the private serde_json RawValue token, its custom visitors, and the redundant full-Value parsing path.
  • Keep field-presence validation: conflicting result/error members are rejected even when one or both values are null. A lone null result remains supported for generic response payloads.
  • Remove the direct RawValue response support introduced by an earlier revision of this PR. An identical external harness confirms that borrowed and boxed RawValue response payloads were unsupported on the base commit; this change does not claim to add that capability.

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:

  • cargo +1.96.0 test -p rmcp --lib: 226 passed
  • cargo +1.96.0 test -p rmcp --lib --all-features: 613 passed
  • cargo +1.96.0 test -p rmcp --test test_deserialization --all-features: 19 passed
  • cargo +1.96.0 clippy -p rmcp --all-targets --all-features -- -D warnings: passed
  • cargo +nightly-2026-09-30 fmt --all -- --check and git diff --check: passed
  • Regression coverage includes conflicting fields, single/batch untagged wrappers, borrowed payloads, null results, and permissive generic payload dispatch.
  • An identical external path-dependency harness (serde 1.0.229, serde_json 1.0.151) was run against base e02efbf, previous head 0a0cfa7, and the buffered-parser revision preceding this cleanup. This external harness was not rerun for 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

AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. An authorized AI assistant prepared this revision and executed the checks listed above. This does not claim unaided human implementation or testing.

@ump45nose
ump45nose requested a review from a team as a code owner September 20, 2026 02:34
@github-actions github-actions Bot added the T-core Core library changes label Sep 20, 2026
@ump45nose

Copy link
Copy Markdown
Contributor Author

Status re-check (Asia/Shanghai 2026-09-22)

Re-verified fork head 6b9f15ec against current upstream main:

  • Branch divergence: ahead 2 / behind 0 (no rebase needed).
  • Local: cargo test -p rmcp --lib → 205 passed, 0 failed (includes test_response_with_result_and_error_is_rejected, test_request_with_stray_result_or_error_is_rejected, and clean-shape / extension-field coverage).
  • Upstream CI on this PR previously completed green (lint, clippy, tests, conformance, CodeQL).

No further code changes in this update — only confirming the PR is still merge-ready relative to main and the regression suite still holds.

Related: #1283

AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. The submission was checked against the prepared artifact and recorded verification evidence.

@jarrettdustinqq jarrettdustinqq left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

ump45nose and others added 4 commits September 28, 2026 07:14
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.
@ump45nose

Copy link
Copy Markdown
Contributor Author

CI repair (Asia/Shanghai 2026-09-28)

Rebased onto current main and fixed the Clippy CI failure (clippy::type_complexity on JsonRpcMessageVisitor's PhantomData<fn() -> (Req, Resp, Noti)> marker).

  • Tip: 29e685d (was e92906f)
  • Divergence vs main: ahead 4 / behind 0
  • Local: cargo clippy -p rmcp --lib --all-features -- -D warnings clean; cargo test -p rmcp --lib 221 passed

Change: introduce JsonRpcMessageVisitorMarker type alias so -D warnings no longer trips on the marker field.


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.

Comment thread crates/rmcp/src/model.rs Outdated
Comment thread crates/rmcp/src/model.rs Outdated
Comment thread crates/rmcp/src/model.rs Outdated
Comment thread crates/rmcp/src/model.rs Outdated
Comment thread crates/rmcp/src/model.rs
Comment on lines +862 to +869
match key.as_str() {
"method" => method = true,
"id" => id = true,
"result" => result = true,
"error" => error = true,
_ => {}
}
map.next_value::<serde::de::IgnoredAny>()?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread crates/rmcp/src/model.rs Outdated
Comment thread crates/rmcp/src/model.rs
Comment on lines +936 to +938
fn deserialize<__D>(deserializer: __D) -> Result<Self, __D::Error>
where
__D: serde::Deserializer<'de>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A plain D matches the other implementations in this PR.

Suggested change
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>,

Comment thread crates/rmcp/src/model.rs Outdated
@github-actions github-actions Bot removed T-dependencies Dependencies related changes T-config Configuration file changes labels Oct 14, 2026
DaleSeo
DaleSeo previously approved these changes Oct 14, 2026

@DaleSeo DaleSeo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround, @ump45nose! This looks good now. I only have one extra comment.

Comment thread crates/rmcp/src/model.rs Outdated
talhayme added a commit to talhayme/rust-sdk that referenced this pull request Oct 14, 2026
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>
connor4312 pushed a commit that referenced this pull request Oct 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants