From 6755d126db6f839a6ec8df7c369b96788a8972a0 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:00:19 -0400 Subject: [PATCH] feat!: relax tool result structuredContent type (SEP-2106) Re-applies #919 (reverted by #932): ToolResultContent.structured_content becomes Option so non-object structured content is accepted, matching CallToolResult and SEP-2106. BREAKING CHANGE: ToolResultContent.structured_content changes from Option to Option. --- crates/rmcp/src/model/content.rs | 4 +-- .../client_json_rpc_message_schema.json | 8 +---- ...lient_json_rpc_message_schema_current.json | 8 +---- .../server_json_rpc_message_schema.json | 8 +---- ...erver_json_rpc_message_schema_current.json | 8 +---- crates/rmcp/tests/test_sampling.rs | 34 +++++++++++++++++++ 6 files changed, 40 insertions(+), 30 deletions(-) diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index 680136f8..cd98f96c 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -10,7 +10,7 @@ // ToolUseContent/ToolResultContent are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value, json}; use super::{Annotations, Meta, resource::ResourceContents}; @@ -207,7 +207,7 @@ pub struct ToolResultContent { pub tool_use_id: String, pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub structured_content: Option, + pub structured_content: Option, #[serde(skip_serializing_if = "Option::is_none")] pub is_error: Option, } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 3bfdb7c4..1108254b 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -2352,13 +2352,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 3bfdb7c4..1108254b 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -2352,13 +2352,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } 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 fcf821f5..c002b180 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 @@ -3454,13 +3454,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index fcf821f5..c002b180 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -3454,13 +3454,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index 83d3f6fb..b108ff41 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -9,6 +9,7 @@ use rmcp::{ model::*, service::{RequestContext, Service}, }; +use rstest::rstest; #[tokio::test] async fn test_basic_sampling_message_creation() -> Result<()> { @@ -370,6 +371,39 @@ fn test_tool_result_content_requires_content() { assert!(err.to_string().contains("missing field `content`")); } +#[rstest] +#[case::array(serde_json::json!([{ "city": "SF", "temp": 72 }, { "city": "NY", "temp": 65 }]))] +#[case::string(serde_json::json!("sunny"))] +#[case::integer(serde_json::json!(42))] +#[case::float(serde_json::json!(3.14))] +#[case::boolean(serde_json::json!(true))] +fn tool_result_content_round_trips_non_object_structured_content( + #[case] structured: serde_json::Value, +) -> Result<()> { + let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("x")]); + tool_result.structured_content = Some(structured); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(tool_result, deserialized); + + Ok(()) +} + +// `null` is a valid JSON value, but `Option` deserializes JSON `null` +// as `None`, so `Some(Value::Null)` collapses to `None` on round-trip. +#[test] +fn tool_result_content_null_structured_content_round_trips_to_none() -> Result<()> { + let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("x")]); + tool_result.structured_content = Some(serde_json::Value::Null); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(deserialized.structured_content, None); + + Ok(()) +} + #[tokio::test] async fn test_sampling_message_with_tool_use() -> Result<()> { let message = SamplingMessage::assistant_tool_use(