diff --git a/crates/rmcp/src/handler/server/wrapper/json.rs b/crates/rmcp/src/handler/server/wrapper/json.rs index 7c5297963..5f2d336bb 100644 --- a/crates/rmcp/src/handler/server/wrapper/json.rs +++ b/crates/rmcp/src/handler/server/wrapper/json.rs @@ -13,7 +13,7 @@ use crate::{ /// When used with tools, this wrapper indicates that the value should be /// serialized as structured JSON content with an associated schema. /// The framework will place the JSON in the `structured_content` field -/// of the tool result rather than the regular `content` field. +/// of the tool result and leave the regular `content` field empty. #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct Json(pub T); diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 06ed855e1..86df803b8 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3935,11 +3935,14 @@ impl CallToolResult { meta: None, } } - /// Create a successful tool result with structured content + /// Create a successful tool result with structured content. + /// + /// This leaves `content` empty. Use [`Self::with_content`] when the result + /// should also include a human-readable or backwards-compatible rendering. /// /// # Example /// - /// ```rust,ignore + /// ``` /// use rmcp::model::CallToolResult; /// use serde_json::json; /// @@ -3948,21 +3951,26 @@ impl CallToolResult { /// "humidity": 65, /// "description": "Partly cloudy" /// })); + /// + /// assert!(result.content.is_empty()); /// ``` pub fn structured(value: Value) -> Self { CallToolResult { result_type: Some(ResultType::COMPLETE), - content: vec![ContentBlock::text(value.to_string())], + content: Vec::new(), structured_content: Some(value), is_error: Some(false), meta: None, } } - /// Create an error tool result with structured content + /// Create an error tool result with structured content. + /// + /// This leaves `content` empty. Use [`Self::with_content`] when the result + /// should also include a human-readable or backwards-compatible rendering. /// /// # Example /// - /// ```rust,ignore + /// ``` /// use rmcp::model::CallToolResult; /// use serde_json::json; /// @@ -3975,17 +3983,42 @@ impl CallToolResult { /// "provided": 100 /// } /// })); + /// + /// assert!(result.content.is_empty()); /// ``` pub fn structured_error(value: Value) -> Self { CallToolResult { result_type: Some(ResultType::COMPLETE), - content: vec![ContentBlock::text(value.to_string())], + content: Vec::new(), structured_content: Some(value), is_error: Some(true), meta: None, } } + /// Replace the content blocks on this result. + /// + /// This can pair structured content with a custom rendering or an explicit + /// serialized JSON fallback. + /// + /// # Example + /// + /// ``` + /// use rmcp::model::{CallToolResult, ContentBlock}; + /// use serde_json::json; + /// + /// let value = json!({"rows": [1, 2, 3]}); + /// let fallback = ContentBlock::text(value.to_string()); + /// let result = CallToolResult::structured(value) + /// .with_content(vec![fallback]); + /// + /// assert_eq!(result.content.len(), 1); + /// ``` + pub fn with_content(mut self, content: Vec) -> Self { + self.content = content; + self + } + /// Set the metadata on this result pub fn with_meta(mut self, meta: Option) -> Self { self.meta = meta; diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index f3416e519..265afded2 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -8,7 +8,7 @@ use rmcp::{ }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::json; #[derive(Serialize, Deserialize, JsonSchema)] pub struct CalculationRequest { @@ -147,9 +147,8 @@ async fn test_tool_without_output_schema() { assert!(greeting_tool.output_schema.is_none()); } -#[tokio::test] -async fn test_structured_content_in_call_result() { - // Test creating a CallToolResult with structured content +#[test] +fn structured_should_leave_content_empty() { let structured_data = json!({ "sum": 7, "product": 12 @@ -157,27 +156,13 @@ async fn test_structured_content_in_call_result() { let result = CallToolResult::structured(structured_data.clone()); - assert!(!result.content.is_empty()); - assert!(result.structured_content.is_some()); - - let contents = result.content; - - assert_eq!(contents.len(), 1); - - let content_text = contents.first().unwrap().as_text(); - - assert!(content_text.is_some()); - - let content_value: Value = serde_json::from_str(&content_text.unwrap().text).unwrap(); - - assert_eq!(content_value, structured_data); - assert_eq!(result.structured_content.unwrap(), structured_data); + assert!(result.content.is_empty()); + assert_eq!(result.structured_content, Some(structured_data)); assert_eq!(result.is_error, Some(false)); } -#[tokio::test] -async fn test_structured_error_in_call_result() { - // Test creating a CallToolResult with structured error +#[test] +fn structured_error_should_leave_content_empty() { let error_data = json!({ "error_code": "NOT_FOUND", "message": "User not found" @@ -185,24 +170,27 @@ async fn test_structured_error_in_call_result() { let result = CallToolResult::structured_error(error_data.clone()); - assert!(!result.content.is_empty()); - assert!(result.structured_content.is_some()); - - let contents = result.content; - - assert_eq!(contents.len(), 1); - - let content_text = contents.first().unwrap().as_text(); - - assert!(content_text.is_some()); - - let content_value: Value = serde_json::from_str(&content_text.unwrap().text).unwrap(); - - assert_eq!(content_value, error_data); - assert_eq!(result.structured_content.unwrap(), error_data); + assert!(result.content.is_empty()); + assert_eq!(result.structured_content, Some(error_data)); assert_eq!(result.is_error, Some(true)); } +#[test] +fn with_content_should_add_explicit_json_fallback() { + let structured_data = json!({"rows": [1, 2, 3]}); + let fallback = structured_data.to_string(); + + let result = CallToolResult::structured(structured_data.clone()) + .with_content(vec![ContentBlock::text(&fallback)]); + + assert_eq!(result.content.len(), 1); + assert_eq!( + result.content.first().unwrap().as_text().unwrap().text, + fallback + ); + assert_eq!(result.structured_content, Some(structured_data)); +} + #[tokio::test] async fn test_mutual_exclusivity_validation() { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -236,9 +224,8 @@ async fn test_mutual_exclusivity_validation() { assert!(deserialized.is_ok()); } -#[tokio::test] -async fn test_structured_return_conversion() { - // Test that Json converts to CallToolResult with structured_content +#[test] +fn json_should_convert_without_content() { let calc_result = CalculationResult { sum: 7, product: 12, @@ -253,24 +240,9 @@ async fn test_structured_return_conversion() { panic!("expected complete CallToolResult"); }; - // Tools which return structured content should also return a serialized version as - // Content::text for backwards compatibility. - assert!(!call_result.content.is_empty()); - assert!(call_result.structured_content.is_some()); - - let contents = call_result.content; - - assert_eq!(contents.len(), 1); - - let content_text = contents.first().unwrap().as_text(); - - assert!(content_text.is_some()); - - let content_value: Value = serde_json::from_str(&content_text.unwrap().text).unwrap(); + assert!(call_result.content.is_empty()); let structured_value = call_result.structured_content.unwrap(); - assert_eq!(content_value, structured_value); - assert_eq!(structured_value["sum"], 7); assert_eq!(structured_value["product"], 12); } @@ -294,7 +266,7 @@ async fn test_tool_serialization_with_output_schema() { } #[tokio::test] -async fn test_output_schema_requires_structured_content() { +async fn json_tool_should_return_structured_content_without_fallback() { // Test that tools with output_schema must use structured_content let server = TestServer::new(); let tools = server.tool_router.list_all(); @@ -316,9 +288,8 @@ async fn test_output_schema_requires_structured_content() { panic!("expected complete CallToolResult"); }; - // Verify it has structured_content and content assert!(call_result.structured_content.is_some()); - assert!(!call_result.content.is_empty()); + assert!(call_result.content.is_empty()); } #[tokio::test]