Skip to content

Commit c8c0c0c

Browse files
authored
fix: prevent CallToolResult and GetTaskPayloadResult from shadowing CustomResult in untagged enums (#771)
The `#[serde(default)]` on `CallToolResult.content` (added in #752) made all fields optional, causing `CallToolResult` to greedily match any JSON object during `#[serde(untagged)]` deserialization of `ServerResult`. Similarly, `GetTaskPayloadResult(Value)` matched everything before `CustomResult(Value)` could be reached. Fix by replacing derived `Deserialize` impls with custom ones: - `CallToolResult`: require at least one known field to be present - `GetTaskPayloadResult`: always fail (indistinguishable from `CustomResult` in JSON; construct programmatically via `::new()`)
1 parent 30cdc38 commit c8c0c0c

File tree

2 files changed

+63
-2
lines changed

2 files changed

+63
-2
lines changed

crates/rmcp/src/model.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2691,7 +2691,7 @@ pub type ElicitationCompletionNotification =
26912691
///
26922692
/// Contains the content returned by the tool execution and an optional
26932693
/// flag indicating whether the operation resulted in an error.
2694-
#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)]
2694+
#[derive(Default, Debug, Serialize, Clone, PartialEq)]
26952695
#[serde(rename_all = "camelCase")]
26962696
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
26972697
#[non_exhaustive]
@@ -2710,6 +2710,48 @@ pub struct CallToolResult {
27102710
pub meta: Option<Meta>,
27112711
}
27122712

2713+
// Custom Deserialize implementation that:
2714+
// 1. Defaults `content` to `[]` when the field is missing (lenient per Postel's law)
2715+
// 2. Requires at least one known field to be present, so that `CallToolResult` doesn't
2716+
// greedily match arbitrary JSON objects when used inside `#[serde(untagged)]` enums
2717+
// (e.g. `ServerResult`), which would shadow `CustomResult`.
2718+
impl<'de> Deserialize<'de> for CallToolResult {
2719+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2720+
where
2721+
D: serde::Deserializer<'de>,
2722+
{
2723+
#[derive(Deserialize)]
2724+
#[serde(rename_all = "camelCase")]
2725+
struct Helper {
2726+
content: Option<Vec<Content>>,
2727+
structured_content: Option<Value>,
2728+
is_error: Option<bool>,
2729+
#[serde(rename = "_meta")]
2730+
meta: Option<Meta>,
2731+
}
2732+
2733+
let helper = Helper::deserialize(deserializer)?;
2734+
2735+
if helper.content.is_none()
2736+
&& helper.structured_content.is_none()
2737+
&& helper.is_error.is_none()
2738+
&& helper.meta.is_none()
2739+
{
2740+
return Err(serde::de::Error::custom(
2741+
"expected at least one known CallToolResult field \
2742+
(content, structuredContent, isError, or _meta)",
2743+
));
2744+
}
2745+
2746+
Ok(CallToolResult {
2747+
content: helper.content.unwrap_or_default(),
2748+
structured_content: helper.structured_content,
2749+
is_error: helper.is_error,
2750+
meta: helper.meta,
2751+
})
2752+
}
2753+
}
2754+
27132755
impl CallToolResult {
27142756
/// Create a successful tool result with unstructured content
27152757
pub fn success(content: Vec<Content>) -> Self {

crates/rmcp/src/model/task.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ pub struct GetTaskResult {
123123
/// (e.g., `CallToolResult` for `tools/call`). This is represented as
124124
/// an open object. The payload is the original request's result
125125
/// serialized as a JSON value.
126-
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126+
#[derive(Debug, Clone, PartialEq, Serialize)]
127127
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
128128
#[non_exhaustive]
129129
pub struct GetTaskPayloadResult(pub Value);
@@ -135,6 +135,25 @@ impl GetTaskPayloadResult {
135135
}
136136
}
137137

138+
// Custom Deserialize that always fails, so that `GetTaskPayloadResult` is skipped
139+
// during `#[serde(untagged)]` enum deserialization (e.g. `ServerResult`).
140+
// The payload has the same JSON shape as `CustomResult(Value)`, so they are
141+
// indistinguishable. `CustomResult` acts as the catch-all instead.
142+
// `GetTaskPayloadResult` should be constructed programmatically via `::new()`.
143+
impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult {
144+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
145+
where
146+
D: serde::Deserializer<'de>,
147+
{
148+
// Consume the value so the deserializer state stays consistent.
149+
serde::de::IgnoredAny::deserialize(deserializer)?;
150+
Err(serde::de::Error::custom(
151+
"GetTaskPayloadResult cannot be deserialized directly; \
152+
use CustomResult as the catch-all",
153+
))
154+
}
155+
}
156+
138157
/// Response to a `tasks/cancel` request.
139158
///
140159
/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.

0 commit comments

Comments
 (0)