Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3862,6 +3862,15 @@ pub type ElicitRequest = Request<ElicitationCreateRequestMethod, ElicitRequestPa
// TOOL EXECUTION RESULTS
// =============================================================================

/// Deserialize a field that is present on the wire as `Some`, even when its
/// value is `null`. Combined with `#[serde(default)]`, an absent field stays `None`.
fn deserialize_present_value<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
Value::deserialize(deserializer).map(Some)
}

/// The result of a tool call operation.
///
/// Contains the content returned by the tool execution and an optional
Expand All @@ -3886,7 +3895,9 @@ pub struct CallToolResult {
/// The content returned by the tool (text, images, etc.)
#[serde(default)]
pub content: Vec<ContentBlock>,
/// An optional JSON object that represents the structured result of the tool call
/// An optional JSON value that represents the structured result of the tool call.
/// It can be any JSON value, including `null`; an explicit `null` is kept
/// distinct from an absent field.
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_content: Option<Value>,
/// Whether this result represents an error condition
Expand All @@ -3903,6 +3914,8 @@ pub struct CallToolResult {
// greedily match arbitrary JSON objects when used inside `#[serde(untagged)]` enums
// (e.g. `ServerResult`), which would shadow `CustomResult`.
// 3. Rejects non-`complete` result types so other `ServerResult` variants can match.
// 4. Keeps a present `"structuredContent": null` as `Some(Value::Null)`, distinct
// from an absent field, since structured content can be any JSON value.
impl<'de> Deserialize<'de> for CallToolResult {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
Expand All @@ -3914,6 +3927,7 @@ impl<'de> Deserialize<'de> for CallToolResult {
#[serde(default)]
result_type: Option<ResultType>,
content: Option<Vec<ContentBlock>>,
#[serde(default, deserialize_with = "deserialize_present_value")]
structured_content: Option<Value>,
is_error: Option<bool>,
#[serde(rename = "_meta")]
Expand Down
35 changes: 35 additions & 0 deletions crates/rmcp/tests/test_deserialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,41 @@ mod untagged_server_result {
);
}

#[test]
fn call_tool_result_with_null_structured_content_deserializes_to_correct_variant() {
for payload in [
json!({
"resultType": "complete",
"content": [],
"structuredContent": null
}),
json!({
"resultType": "complete",
"structuredContent": null
}),
json!({ "structuredContent": null }),
] {
let result = parse_result(wrap_response(payload.clone()));
let ServerResult::CallToolResult(result) = result else {
panic!("{payload} should deserialize as CallToolResult, got {result:?}");
};
assert_eq!(result.structured_content, Some(serde_json::Value::Null));
}
}

#[test]
fn null_structured_content_does_not_shadow_custom_result() {
// Counting a present `structuredContent: null` as a known field must not
// make CallToolResult swallow other objects carrying only null values.
let result = parse_result(wrap_response(json!({
"somethingElse": null
})));
assert!(
matches!(result, ServerResult::CustomResult(_)),
"expected CustomResult, got {result:?}"
);
}

#[test]
fn input_required_result_with_meta_deserializes_to_correct_variant() {
let result = parse_result(wrap_response(json!({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@
]
},
"structuredContent": {
"description": "An optional JSON object that represents the structured result of the tool call"
"description": "An optional JSON value that represents the structured result of the tool call.\nIt can be any JSON value, including `null`; an explicit `null` is kept\ndistinct from an absent field."
}
}
},
Expand Down
49 changes: 49 additions & 0 deletions crates/rmcp/tests/test_structured_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,55 @@ fn test_call_tool_result_deserialize_without_content() {
assert!(result.structured_content.is_some());
}

/// Per the 2026-07-28 spec, `structuredContent` can be any JSON value,
/// including `null`. A present `null` must stay distinct from an absent field.
#[test]
fn test_explicit_null_structured_content_is_preserved() {
let json = json!({
"resultType": "complete",
"content": [],
"structuredContent": null
});
let result: CallToolResult = serde_json::from_value(json).unwrap();
assert_eq!(result.structured_content, Some(Value::Null));
}

#[test]
fn test_explicit_null_structured_content_without_content_deserializes() {
let json = json!({
"resultType": "complete",
"structuredContent": null
});
let result: CallToolResult = serde_json::from_value(json).unwrap();
assert!(result.content.is_empty());
assert_eq!(result.structured_content, Some(Value::Null));
}

#[test]
fn test_absent_structured_content_is_none() {
let json = json!({
"resultType": "complete",
"content": []
});
let result: CallToolResult = serde_json::from_value(json).unwrap();
assert_eq!(result.structured_content, None);
}

#[test]
fn test_null_structured_content_serialization_roundtrip() {
let with_null = CallToolResult::structured(Value::Null);
let v = serde_json::to_value(&with_null).unwrap();
assert_eq!(v.get("structuredContent"), Some(&Value::Null));
let deserialized: CallToolResult = serde_json::from_value(v).unwrap();
assert_eq!(deserialized, with_null);

let absent = CallToolResult::success(vec![]);
let v = serde_json::to_value(&absent).unwrap();
assert!(v.get("structuredContent").is_none());
let deserialized: CallToolResult = serde_json::from_value(v).unwrap();
assert_eq!(deserialized.structured_content, None);
}

#[tokio::test]
async fn test_tool_with_array_output_schema() {
let server = TestServer::new();
Expand Down
Loading