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
53 changes: 4 additions & 49 deletions crates/goose/src/conversation/message.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use crate::conversation::tool_result_serde;
use crate::mcp_utils::ToolResult;
use crate::mcp_utils::{extract_text_from_resource, ToolResult};
use crate::utils::sanitize_unicode_tags;
Comment on lines 1 to 3
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

conversation/message.rs now imports crate::providers::utils, creating a bidirectional coupling (providers already depend on conversation::message); consider moving extract_text_from_resource to a neutral module (e.g., crate::utils/mcp_utils) to keep the core message model provider-agnostic.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems like a good idea to ,e

use chrono::Utc;
use rmcp::model::{
AnnotateAble, CallToolRequestParams, CallToolResult, Content, ImageContent, JsonObject,
PromptMessage, PromptMessageContent, PromptMessageRole, RawContent, RawImageContent,
RawTextContent, ResourceContents, Role, TextContent,
RawTextContent, Role, TextContent,
};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashSet;
Expand Down Expand Up @@ -527,13 +527,7 @@ impl From<Content> for MessageContent {
}
RawContent::ResourceLink(_link) => MessageContent::text("[Resource link]"),
RawContent::Resource(resource) => {
let text = match &resource.resource {
ResourceContents::TextResourceContents { text, .. } => text.clone(),
ResourceContents::BlobResourceContents { blob, .. } => {
format!("[Binary content: {}]", blob.clone())
}
};
MessageContent::text(text)
MessageContent::text(extract_text_from_resource(&resource.resource))
}
RawContent::Audio(_) => {
MessageContent::text("[Audio content: not supported]".to_string())
Expand All @@ -558,15 +552,7 @@ impl From<PromptMessage> for Message {
}
PromptMessageContent::ResourceLink { .. } => MessageContent::text("[Resource link]"),
PromptMessageContent::Resource { resource } => {
// For resources, convert to text content with the resource text
match &resource.resource {
ResourceContents::TextResourceContents { text, .. } => {
MessageContent::text(text.clone())
}
ResourceContents::BlobResourceContents { blob, .. } => {
MessageContent::text(format!("[Binary content: {}]", blob.clone()))
}
}
MessageContent::text(extract_text_from_resource(&resource.resource))
}
Comment on lines 554 to 556
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The previous test coverage for blob resources in From<PromptMessage> was removed, and the new behavior (base64-decoding blob resources into text) isn’t asserted anywhere in this module; add/restore a test that constructs a BlobResourceContents with base64-encoded UTF-8 and verifies the resulting MessageContent::Text is the decoded text.

Copilot uses AI. Check for mistakes.
};

Expand Down Expand Up @@ -1185,37 +1171,6 @@ mod tests {
}
}

#[test]
fn test_from_prompt_message_blob_resource() {
let resource = ResourceContents::BlobResourceContents {
uri: "file:///test.bin".to_string(),
mime_type: Some("application/octet-stream".to_string()),
blob: "binary_data".to_string(),
meta: None,
};

let prompt_content = PromptMessageContent::Resource {
resource: RawEmbeddedResource {
resource,
meta: None,
}
.no_annotation(),
};

let prompt_message = PromptMessage {
role: PromptMessageRole::User,
content: prompt_content,
};

let message = Message::from(prompt_message);

if let MessageContent::Text(text_content) = &message.content[0] {
assert_eq!(text_content.text, "[Binary content: binary_data]");
} else {
panic!("Expected MessageContent::Text");
}
}

#[test]
fn test_from_prompt_message() {
// Test user message conversion
Expand Down
106 changes: 105 additions & 1 deletion crates/goose/src/mcp_utils.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,108 @@
use base64::Engine;
pub use rmcp::model::ErrorData;
use rmcp::model::ResourceContents;

/// Type alias for tool results
pub type ToolResult<T> = Result<T, ErrorData>;

pub fn extract_text_from_resource(resource: &ResourceContents) -> String {
match resource {
ResourceContents::TextResourceContents { text, .. } => text.clone(),
ResourceContents::BlobResourceContents {
blob, mime_type, ..
} => match base64::engine::general_purpose::STANDARD.decode(blob) {
Ok(bytes) => {
let byte_len = bytes.len();
match String::from_utf8(bytes) {
Ok(text) => text,
Err(_) => {
let mime = mime_type
.as_ref()
.map(|m| m.as_str())
.unwrap_or("application/octet-stream");
format!("[Binary content ({}) - {} bytes]", mime, byte_len)
}
}
}
Err(_) => blob.clone(),
},
}
}

#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;

#[test_case("Hello, World!", "Hello, World!" ; "simple text")]
#[test_case("Hello from GitHub!", "Hello from GitHub!" ; "github content")]
#[test_case("", "" ; "empty text")]
fn test_extract_text_from_text_resource(input: &str, expected: &str) {
let resource = ResourceContents::TextResourceContents {
uri: "file:///test.txt".to_string(),
mime_type: Some("text/plain".to_string()),
text: input.to_string(),
meta: None,
};
assert_eq!(extract_text_from_resource(&resource), expected);
}

#[test_case("Hello from GitHub!", "Hello from GitHub!" ; "utf8 markdown")]
#[test_case("Simple text", "Simple text" ; "utf8 plain")]
fn test_extract_text_from_blob_utf8(input: &str, expected: &str) {
let blob = base64::engine::general_purpose::STANDARD.encode(input.as_bytes());
let resource = ResourceContents::BlobResourceContents {
uri: "github://repo/file.md".to_string(),
mime_type: Some("text/markdown".to_string()),
blob,
meta: None,
};
assert_eq!(extract_text_from_resource(&resource), expected);
}

#[test]
fn test_extract_text_from_blob_binary() {
let binary_data: Vec<u8> = vec![0xFF, 0xFE, 0x00, 0x01, 0x89, 0x50, 0x4E, 0x47];
let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data);

let resource = ResourceContents::BlobResourceContents {
uri: "file:///image.png".to_string(),
mime_type: Some("image/png".to_string()),
blob,
meta: None,
};

assert_eq!(
extract_text_from_resource(&resource),
"[Binary content (image/png) - 8 bytes]"
);
}

#[test]
fn test_extract_text_from_blob_binary_no_mime_type() {
let binary_data: Vec<u8> = vec![0xFF, 0xFE];
let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data);

let resource = ResourceContents::BlobResourceContents {
uri: "file:///unknown".to_string(),
mime_type: None,
blob,
meta: None,
};

assert_eq!(
extract_text_from_resource(&resource),
"[Binary content (application/octet-stream) - 2 bytes]"
);
}

#[test]
fn test_extract_text_from_blob_invalid_base64() {
let resource = ResourceContents::BlobResourceContents {
uri: "file:///test.txt".to_string(),
mime_type: Some("text/plain".to_string()),
blob: "not valid base64!!!".to_string(),
meta: None,
};
assert_eq!(extract_text_from_resource(&resource), "not valid base64!!!");
}
}
12 changes: 4 additions & 8 deletions crates/goose/src/providers/formats/openai.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::conversation::message::{Message, MessageContent, ProviderMetadata};
use crate::mcp_utils::extract_text_from_resource;
use crate::model::ModelConfig;
use crate::providers::base::{ProviderUsage, Usage};
use crate::providers::utils::{
Expand All @@ -10,8 +11,8 @@ use async_stream::try_stream;
use chrono;
use futures::Stream;
use rmcp::model::{
object, AnnotateAble, CallToolRequestParams, Content, ErrorCode, ErrorData, RawContent,
ResourceContents, Role, Tool,
object, AnnotateAble, CallToolRequestParams, Content, ErrorCode, ErrorData, RawContent, Role,
Tool,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
Expand Down Expand Up @@ -173,12 +174,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
}));
}
RawContent::Resource(resource) => {
let text = match &resource.resource {
ResourceContents::TextResourceContents {
text, ..
} => text.clone(),
_ => String::new(),
};
let text = extract_text_from_resource(&resource.resource);
tool_content.push(Content::text(text));
}
Comment on lines 176 to 179
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

This change alters OpenAI tool-response formatting for RawContent::Resource (now including decoded blob text), but there’s no test covering this path; add a unit test that builds a tool result containing a blob resource and asserts the formatted payload includes the decoded text.

Copilot uses AI. Check for mistakes.
_ => {
Expand Down