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()`)
This commit is contained in:
parent
30cdc38c9a
commit
c8c0c0cffc
2 changed files with 63 additions and 2 deletions
|
|
@ -2691,7 +2691,7 @@ pub type ElicitationCompletionNotification =
|
||||||
///
|
///
|
||||||
/// Contains the content returned by the tool execution and an optional
|
/// Contains the content returned by the tool execution and an optional
|
||||||
/// flag indicating whether the operation resulted in an error.
|
/// flag indicating whether the operation resulted in an error.
|
||||||
#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)]
|
#[derive(Default, Debug, Serialize, Clone, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
|
|
@ -2710,6 +2710,48 @@ pub struct CallToolResult {
|
||||||
pub meta: Option<Meta>,
|
pub meta: Option<Meta>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom Deserialize implementation that:
|
||||||
|
// 1. Defaults `content` to `[]` when the field is missing (lenient per Postel's law)
|
||||||
|
// 2. Requires at least one known field to be present, so that `CallToolResult` doesn't
|
||||||
|
// greedily match arbitrary JSON objects when used inside `#[serde(untagged)]` enums
|
||||||
|
// (e.g. `ServerResult`), which would shadow `CustomResult`.
|
||||||
|
impl<'de> Deserialize<'de> for CallToolResult {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Helper {
|
||||||
|
content: Option<Vec<Content>>,
|
||||||
|
structured_content: Option<Value>,
|
||||||
|
is_error: Option<bool>,
|
||||||
|
#[serde(rename = "_meta")]
|
||||||
|
meta: Option<Meta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let helper = Helper::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
if helper.content.is_none()
|
||||||
|
&& helper.structured_content.is_none()
|
||||||
|
&& helper.is_error.is_none()
|
||||||
|
&& helper.meta.is_none()
|
||||||
|
{
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"expected at least one known CallToolResult field \
|
||||||
|
(content, structuredContent, isError, or _meta)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CallToolResult {
|
||||||
|
content: helper.content.unwrap_or_default(),
|
||||||
|
structured_content: helper.structured_content,
|
||||||
|
is_error: helper.is_error,
|
||||||
|
meta: helper.meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl CallToolResult {
|
impl CallToolResult {
|
||||||
/// Create a successful tool result with unstructured content
|
/// Create a successful tool result with unstructured content
|
||||||
pub fn success(content: Vec<Content>) -> Self {
|
pub fn success(content: Vec<Content>) -> Self {
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ pub struct GetTaskResult {
|
||||||
/// (e.g., `CallToolResult` for `tools/call`). This is represented as
|
/// (e.g., `CallToolResult` for `tools/call`). This is represented as
|
||||||
/// an open object. The payload is the original request's result
|
/// an open object. The payload is the original request's result
|
||||||
/// serialized as a JSON value.
|
/// serialized as a JSON value.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub struct GetTaskPayloadResult(pub Value);
|
pub struct GetTaskPayloadResult(pub Value);
|
||||||
|
|
@ -135,6 +135,25 @@ impl GetTaskPayloadResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom Deserialize that always fails, so that `GetTaskPayloadResult` is skipped
|
||||||
|
// during `#[serde(untagged)]` enum deserialization (e.g. `ServerResult`).
|
||||||
|
// The payload has the same JSON shape as `CustomResult(Value)`, so they are
|
||||||
|
// indistinguishable. `CustomResult` acts as the catch-all instead.
|
||||||
|
// `GetTaskPayloadResult` should be constructed programmatically via `::new()`.
|
||||||
|
impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
// Consume the value so the deserializer state stays consistent.
|
||||||
|
serde::de::IgnoredAny::deserialize(deserializer)?;
|
||||||
|
Err(serde::de::Error::custom(
|
||||||
|
"GetTaskPayloadResult cannot be deserialized directly; \
|
||||||
|
use CustomResult as the catch-all",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Response to a `tasks/cancel` request.
|
/// Response to a `tasks/cancel` request.
|
||||||
///
|
///
|
||||||
/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.
|
/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue