fix: match shape of the calltoolresult schema (#377)

BREAKING CHANGE: makes the `content` field non-optional
This commit is contained in:
Peter L 2025-08-19 15:58:25 +09:30 • committed by GitHub
commit a47cbaac4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 26 additions and 29 deletions

View file

@ -183,7 +183,7 @@ impl<'de> Deserialize<'de> for ProtocolVersion {
pub enum NumberOrString { pub enum NumberOrString {
/// A numeric identifier /// A numeric identifier
Number(u32), Number(u32),
/// A string identifier /// A string identifier
String(Arc<str>), String(Arc<str>),
} }
@ -1257,8 +1257,7 @@ pub type CreateElicitationRequest =
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CallToolResult { pub struct CallToolResult {
/// The content returned by the tool (text, images, etc.) /// The content returned by the tool (text, images, etc.)
#[serde(skip_serializing_if = "Option::is_none")] pub content: Vec<Content>,
pub content: Option<Vec<Content>>,
/// An optional JSON object that represents the structured result of the tool call /// An optional JSON object that represents the structured result of the tool call
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub structured_content: Option<Value>, pub structured_content: Option<Value>,
@ -1271,7 +1270,7 @@ 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 {
CallToolResult { CallToolResult {
content: Some(content), content,
structured_content: None, structured_content: None,
is_error: Some(false), is_error: Some(false),
} }
@ -1279,7 +1278,7 @@ impl CallToolResult {
/// Create an error tool result with unstructured content /// Create an error tool result with unstructured content
pub fn error(content: Vec<Content>) -> Self { pub fn error(content: Vec<Content>) -> Self {
CallToolResult { CallToolResult {
content: Some(content), content,
structured_content: None, structured_content: None,
is_error: Some(true), is_error: Some(true),
} }
@ -1300,7 +1299,7 @@ impl CallToolResult {
/// ``` /// ```
pub fn structured(value: Value) -> Self { pub fn structured(value: Value) -> Self {
CallToolResult { CallToolResult {
content: Some(vec![Content::text(value.to_string())]), content: vec![Content::text(value.to_string())],
structured_content: Some(value), structured_content: Some(value),
is_error: Some(false), is_error: Some(false),
} }
@ -1325,7 +1324,7 @@ impl CallToolResult {
/// ``` /// ```
pub fn structured_error(value: Value) -> Self { pub fn structured_error(value: Value) -> Self {
CallToolResult { CallToolResult {
content: Some(vec![Content::text(value.to_string())]), content: vec![Content::text(value.to_string())],
structured_content: Some(value), structured_content: Some(value),
is_error: Some(true), is_error: Some(true),
} }
@ -1341,10 +1340,10 @@ impl CallToolResult {
where where
T: DeserializeOwned, T: DeserializeOwned,
{ {
let raw_text = match (self.structured_content, &self.content) { let raw_text = match (self.structured_content, &self.content.first()) {
(Some(value), _) => return serde_json::from_value(value), (Some(value), _) => return serde_json::from_value(value),
(None, Some(contents)) => { (None, Some(contents)) => {
if let Some(text) = contents.first().and_then(|c| c.as_text()) { if let Some(text) = contents.as_text() {
let text = &text.text; let text = &text.text;
Some(text) Some(text)
} else { } else {
@ -1379,13 +1378,13 @@ impl<'de> Deserialize<'de> for CallToolResult {
let helper = CallToolResultHelper::deserialize(deserializer)?; let helper = CallToolResultHelper::deserialize(deserializer)?;
let result = CallToolResult { let result = CallToolResult {
content: helper.content, content: helper.content.unwrap_or_default(),
structured_content: helper.structured_content, structured_content: helper.structured_content,
is_error: helper.is_error, is_error: helper.is_error,
}; };
// Validate mutual exclusivity // Validate mutual exclusivity
if result.content.is_none() && result.structured_content.is_none() { if result.content.is_empty() && result.structured_content.is_none() {
return Err(serde::de::Error::custom( return Err(serde::de::Error::custom(
"CallToolResult must have either content or structured_content", "CallToolResult must have either content or structured_content",
)); ));

View file

@ -304,10 +304,7 @@
"properties": { "properties": {
"content": { "content": {
"description": "The content returned by the tool (text, images, etc.)", "description": "The content returned by the tool (text, images, etc.)",
"type": [ "type": "array",
"array",
"null"
],
"items": { "items": {
"$ref": "#/definitions/Annotated" "$ref": "#/definitions/Annotated"
} }
@ -322,7 +319,10 @@
"structuredContent": { "structuredContent": {
"description": "An optional JSON object that represents the structured result of the tool call" "description": "An optional JSON object that represents the structured result of the tool call"
} }
} },
"required": [
"content"
]
}, },
"CancelledNotificationMethod": { "CancelledNotificationMethod": {
"type": "string", "type": "string",

View file

@ -122,10 +122,10 @@ async fn test_structured_content_in_call_result() {
let result = CallToolResult::structured(structured_data.clone()); let result = CallToolResult::structured(structured_data.clone());
assert!(result.content.is_some()); assert!(!result.content.is_empty());
assert!(result.structured_content.is_some()); assert!(result.structured_content.is_some());
let contents = result.content.unwrap(); let contents = result.content;
assert_eq!(contents.len(), 1); assert_eq!(contents.len(), 1);
@ -150,10 +150,10 @@ async fn test_structured_error_in_call_result() {
let result = CallToolResult::structured_error(error_data.clone()); let result = CallToolResult::structured_error(error_data.clone());
assert!(result.content.is_some()); assert!(!result.content.is_empty());
assert!(result.structured_content.is_some()); assert!(result.structured_content.is_some());
let contents = result.content.unwrap(); let contents = result.content;
assert_eq!(contents.len(), 1); assert_eq!(contents.len(), 1);
@ -217,10 +217,10 @@ async fn test_structured_return_conversion() {
// Tools which return structured content should also return a serialized version as // Tools which return structured content should also return a serialized version as
// Content::text for backwards compatibility. // Content::text for backwards compatibility.
assert!(call_result.content.is_some()); assert!(!call_result.content.is_empty());
assert!(call_result.structured_content.is_some()); assert!(call_result.structured_content.is_some());
let contents = call_result.content.unwrap(); let contents = call_result.content;
assert_eq!(contents.len(), 1); assert_eq!(contents.len(), 1);
@ -278,5 +278,5 @@ async fn test_output_schema_requires_structured_content() {
// Verify it has structured_content and content // Verify it has structured_content and content
assert!(call_result.structured_content.is_some()); assert!(call_result.structured_content.is_some());
assert!(call_result.content.is_some()); assert!(!call_result.content.is_empty());
} }

View file

@ -301,8 +301,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> {
let result_text = result let result_text = result
.content .content
.as_ref() .first()
.and_then(|contents| contents.first())
.and_then(|content| content.raw.as_text()) .and_then(|content| content.raw.as_text())
.map(|text| text.text.as_str()) .map(|text| text.text.as_str())
.expect("Expected text content"); .expect("Expected text content");
@ -330,8 +329,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> {
let some_result_text = some_result let some_result_text = some_result
.content .content
.as_ref() .first()
.and_then(|contents| contents.first())
.and_then(|content| content.raw.as_text()) .and_then(|content| content.raw.as_text())
.map(|text| text.text.as_str()) .map(|text| text.text.as_str())
.expect("Expected text content"); .expect("Expected text content");

View file

@ -85,8 +85,8 @@ impl ChatSession {
if result.is_error.is_some_and(|b| b) { if result.is_error.is_some_and(|b| b) {
self.messages self.messages
.push(Message::user("tool call failed, mcp call error")); .push(Message::user("tool call failed, mcp call error"));
} else if let Some(contents) = &result.content { } else {
contents.iter().for_each(|content| { result.content.iter().for_each(|content| {
if let Some(content_text) = content.as_text() { if let Some(content_text) = content.as_text() {
let json_result = serde_json::from_str::<serde_json::Value>( let json_result = serde_json::from_str::<serde_json::Value>(
&content_text.text, &content_text.text,