Skip to content

InputRequiredResult with _meta silently deserializes as CallToolResult #1094

Description

@zs-dima

A server that attaches _meta to an MRTR InputRequiredResult produces a response
the client silently misinterprets: call_tool_once returns
CallToolResponse::Complete(CallToolResult { content: [], .. }) instead of
CallToolResponse::InputRequired(..). The inputRequests and requestState fields are dropped.
There is no error — the dialogue simply appears to have finished with empty content.

Cause

ServerResult is #[serde(untagged)] and lists CallToolResult before
InputRequiredResult (src/model.rs, ts_union! { export type ServerResult = ... }).

CallToolResult's hand-written Deserialize (src/model.rs, ~line 3800) guards against
greedy matching by requiring at least one of content, structuredContent, isError, or
_meta to be present — but it never checks resultType. An InputRequiredResult with _meta
satisfies that guard, so the earlier variant wins and the later, correct variant is never tried.

The same class of bug was anticipated for TaskAckResult; the union even carries a comment about ordering:

// TaskAckResult must come after CallToolResult/InputRequiredResult in this
// untagged union: it only carries `resultType`, so it would otherwise
// shadow any result that includes `resultType: "complete"`.

CallToolResult shadowing InputRequiredResult is the mirror image of that, and it is not guarded.

Reproduction

Version: rmcp 3.0.0

let mut meta = MetaObject::new();
meta.insert("example.com/replica".to_owned(), "r1".into());

let result = InputRequiredResult::new(Some(requests), Some("sealed".to_owned()))
    .with_meta(meta);

let json = serde_json::to_value(&result).unwrap();

// Direct parse: fine.
serde_json::from_value::<InputRequiredResult>(json.clone()).unwrap();

// Through the union: wrong variant, fields silently dropped.
assert!(matches!(
    serde_json::from_value::<ServerResult>(json).unwrap(),
    ServerResult::InputRequiredResult(_)
)); // fails — it is CallToolResult

Removing .with_meta(..) makes it parse correctly, which is what makes this so easy to
ship by accident: it works until the first time a server adds metadata.

Impact

InputRequiredResult::with_meta is public API and InputRequiredResult.meta is a public
field, so this is the documented way to attach metadata to an intermediate MRTR result.
Any server that uses it breaks every rmcp client, and the failure is silent: the client
sees a completed tool call with no content rather than a protocol error.

Suggested fix

Add a resultType check to CallToolResult::deserialize, symmetric to the one
InputRequiredResult already has — reject when resultType is present and is not
"complete". That keeps the lenient behaviour for pre-2026-07-28 servers (absent
resultType still means complete) while stopping the variant from shadowing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P1High: significant functionality gap or spec violation

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions