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

View file

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

View file

@ -122,10 +122,10 @@ async fn test_structured_content_in_call_result() {
let result = CallToolResult::structured(structured_data.clone());
assert!(result.content.is_some());
assert!(!result.content.is_empty());
assert!(result.structured_content.is_some());
let contents = result.content.unwrap();
let contents = result.content;
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());
assert!(result.content.is_some());
assert!(!result.content.is_empty());
assert!(result.structured_content.is_some());
let contents = result.content.unwrap();
let contents = result.content;
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
// Content::text for backwards compatibility.
assert!(call_result.content.is_some());
assert!(!call_result.content.is_empty());
assert!(call_result.structured_content.is_some());
let contents = call_result.content.unwrap();
let contents = call_result.content;
assert_eq!(contents.len(), 1);
@ -278,5 +278,5 @@ async fn test_output_schema_requires_structured_content() {
// Verify it has structured_content and content
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
.content
.as_ref()
.and_then(|contents| contents.first())
.first()
.and_then(|content| content.raw.as_text())
.map(|text| text.text.as_str())
.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
.content
.as_ref()
.and_then(|contents| contents.first())
.first()
.and_then(|content| content.raw.as_text())
.map(|text| text.text.as_str())
.expect("Expected text content");

View file

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