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.

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