Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use crate::conversation::message::{
ActionRequiredData, Message, MessageContent, ProviderMetadata, SystemNotificationType,
ToolRequest,
};
use crate::conversation::tool_result_serde::call_tool_result;
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
use crate::mcp_utils::ToolResult;
use crate::permission::permission_inspector::PermissionInspector;
Expand Down Expand Up @@ -1123,6 +1124,8 @@ impl Agent {

match item {
ToolStreamItem::Result(output) => {
let output = call_tool_result::validate(output);

if enable_extension_request_ids.contains(&request_id)
&& output.is_err()
{
Expand Down
2 changes: 1 addition & 1 deletion crates/goose/src/conversation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use thiserror::Error;
use utoipa::ToSchema;

pub mod message;
mod tool_result_serde;
pub mod tool_result_serde;

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
pub struct Conversation(Vec<Message>);
Expand Down
95 changes: 94 additions & 1 deletion crates/goose/src/conversation/tool_result_serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,17 @@ pub mod call_tool_result {
},
}

let format = ResultFormat::deserialize(deserializer)?;
let original_value = serde_json::Value::deserialize(deserializer)?;

let format = ResultFormat::deserialize(&original_value).map_err(|e| {
tracing::debug!(
"Failed to deserialize call_tool_result: {}. Original data: {}",
e,
serde_json::to_string(&original_value)
.unwrap_or_else(|_| "<invalid json>".to_string())
);
serde::de::Error::custom(e)
})?;

match format {
ResultFormat::NewSuccess { status, value } => {
Expand Down Expand Up @@ -141,4 +151,87 @@ pub mod call_tool_result {
}
}
}

pub fn validate(result: ToolResult<CallToolResult>) -> ToolResult<CallToolResult> {
match &result {
Ok(call_tool_result) => match serde_json::to_string(call_tool_result) {
Ok(json_str) => match serde_json::from_str::<CallToolResult>(&json_str) {
Ok(_) => result,
Err(e) => {
tracing::error!("CallToolResult failed validation by deserialization: {}. Original data: {}", e, json_str);
Err(ErrorData {
code: ErrorCode::INTERNAL_ERROR,
message: Cow::from(format!("Tool result validation failed: {}", e)),
data: None,
})
}
},
Err(e) => {
tracing::error!("CallToolResult failed serialization: {}", e);
Err(ErrorData {
code: ErrorCode::INTERNAL_ERROR,
message: Cow::from(format!("Tool result serialization failed: {}", e)),
data: None,
})
}
},
Comment on lines +157 to +177
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This validation performs an unnecessary serialize->deserialize round-trip. Since CallToolResult is from the rmcp crate which performs validation during deserialization, you can validate directly by checking if content is empty and structured_content is None, matching the MCP standard constraint mentioned in the PR description.

Suggested change
Ok(call_tool_result) => match serde_json::to_string(call_tool_result) {
Ok(json_str) => match serde_json::from_str::<CallToolResult>(&json_str) {
Ok(_) => result,
Err(e) => {
tracing::error!("CallToolResult failed validation by deserialization: {}. Original data: {}", e, json_str);
Err(ErrorData {
code: ErrorCode::INTERNAL_ERROR,
message: Cow::from(format!("Tool result validation failed: {}", e)),
data: None,
})
}
},
Err(e) => {
tracing::error!("CallToolResult failed serialization: {}", e);
Err(ErrorData {
code: ErrorCode::INTERNAL_ERROR,
message: Cow::from(format!("Tool result serialization failed: {}", e)),
data: None,
})
}
},
Ok(call_tool_result) => {
let has_empty_content = call_tool_result.content.is_empty();
let has_no_structured_content = call_tool_result.structured_content.is_none();
if has_empty_content && has_no_structured_content {
tracing::error!(
"CallToolResult failed validation: content must not be empty when structured_content is None"
);
Err(ErrorData {
code: ErrorCode::INTERNAL_ERROR,
message: Cow::from(
"Tool result validation failed: content must not be empty when structured_content is None",
),
data: None,
})
} else {
result
}
}

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could, but it introduced duplicated logic in goose and rmcp. So it would be good to reuse the deserialization function in rmcp to use it as source of truth

Err(_) => result,
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::{CallToolResult, Content, ErrorCode, ErrorData};
use std::borrow::Cow;
#[test]
fn test_validate_accepts_valid_call_tool_result() {
let valid_result = CallToolResult {
content: vec![Content::text("test")],
is_error: Some(false),
structured_content: None,
meta: None,
};

let tool_result: ToolResult<CallToolResult> = Ok(valid_result);
let validated = call_tool_result::validate(tool_result);

assert!(
validated.is_ok(),
"Expected validation to pass for valid CallToolResult"
);
}
#[test]
fn test_validate_returns_error_for_invalid_calltoolresult() {
let valid_result = CallToolResult {
content: vec![],
is_error: Some(false),
structured_content: None,
meta: None,
};

let tool_result: ToolResult<CallToolResult> = Ok(valid_result);
let validated = call_tool_result::validate(tool_result);

assert!(validated.is_err());
assert!(validated
.unwrap_err()
.message
.contains("Tool result validation failed"))
}

#[test]
fn test_validate_passes_through_errors() {
let error_result: ToolResult<CallToolResult> = Err(ErrorData {
code: ErrorCode::INTERNAL_ERROR,
message: Cow::from("test error"),
data: None,
});

let validated = call_tool_result::validate(error_result.clone());

assert!(validated.is_err());
assert_eq!(validated.unwrap_err().message, "test error");
}
}
Loading