diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index ec7b93b3e..0a5758ff9 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3862,6 +3862,15 @@ pub type ElicitRequest = Request(deserializer: D) -> Result, 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 @@ -3886,7 +3895,9 @@ pub struct CallToolResult { /// The content returned by the tool (text, images, etc.) #[serde(default)] pub content: Vec, - /// 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, /// Whether this result represents an error condition @@ -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(deserializer: D) -> Result where @@ -3914,6 +3927,7 @@ impl<'de> Deserialize<'de> for CallToolResult { #[serde(default)] result_type: Option, content: Option>, + #[serde(default, deserialize_with = "deserialize_present_value")] structured_content: Option, is_error: Option, #[serde(rename = "_meta")] diff --git a/crates/rmcp/tests/test_deserialization.rs b/crates/rmcp/tests/test_deserialization.rs index 857077015..be457d142 100644 --- a/crates/rmcp/tests/test_deserialization.rs +++ b/crates/rmcp/tests/test_deserialization.rs @@ -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!({ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index e220078d7..cebb3ed37 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -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." } } }, diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index f3416e519..dd8b72395 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -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();