use serde::{Deserialize, Serialize};
use serde_json::Value;
use synwire_core::tools::{BinaryResult, ToolContentType, ToolOutput, ToolResultStatus};
use crate::error::McpAdapterError;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
#[non_exhaustive]
pub enum McpContentBlock {
Text {
text: String,
},
#[serde(rename_all = "camelCase")]
Image {
mime_type: String,
data: String,
},
#[serde(rename_all = "camelCase")]
Resource {
uri: String,
mime_type: Option<String>,
text: Option<String>,
},
#[serde(rename_all = "camelCase")]
EmbeddedResource {
uri: String,
mime_type: Option<String>,
text: Option<String>,
blob: Option<String>,
},
#[serde(rename_all = "camelCase")]
Audio {
mime_type: String,
data: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpToolCallResponse {
pub content: Vec<McpContentBlock>,
#[serde(default)]
pub is_error: bool,
}
pub fn convert_mcp_response_to_tool_output(raw: Value) -> Result<ToolOutput, McpAdapterError> {
let response: McpToolCallResponse = serde_json::from_value(raw)?;
let status = if response.is_error {
ToolResultStatus::Failure
} else {
ToolResultStatus::Success
};
let mut text_parts: Vec<String> = Vec::new();
let mut binary_results: Vec<BinaryResult> = Vec::new();
let mut artifact: Option<Value> = None;
let mut content_type = ToolContentType::Text;
for block in response.content {
match block {
McpContentBlock::Text { text } => {
text_parts.push(text);
content_type = ToolContentType::Text;
}
McpContentBlock::Image { mime_type, data } => {
let bytes = base64_decode(&data);
binary_results.push(BinaryResult { bytes, mime_type });
content_type = ToolContentType::Image;
}
McpContentBlock::Resource {
uri,
mime_type,
text,
} => {
let desc = text.as_deref().unwrap_or(&uri);
text_parts.push(format!("[resource: {desc}]"));
let resource_meta = serde_json::json!({
"uri": uri,
"mime_type": mime_type,
});
artifact = Some(resource_meta);
}
McpContentBlock::EmbeddedResource {
uri,
mime_type,
text,
blob,
} => {
if let Some(t) = text {
text_parts.push(t);
} else if let Some(b) = blob {
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".into());
binary_results.push(BinaryResult {
bytes: base64_decode(&b),
mime_type: mime,
});
content_type = ToolContentType::File;
}
let _ = uri; if artifact.is_none() {
artifact = Some(serde_json::json!({ "uri": uri }));
}
}
McpContentBlock::Audio { mime_type, .. } => {
text_parts.push(format!("[unsupported audio content: {mime_type}]"));
}
}
}
Ok(ToolOutput {
content: text_parts.join("\n"),
artifact,
binary_results,
status,
content_type: Some(content_type),
..ToolOutput::default()
})
}
fn base64_decode(s: &str) -> Vec<u8> {
use std::collections::HashMap;
let alphabet: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut lookup: HashMap<u8, u8> = HashMap::new();
for (i, &c) in alphabet.iter().enumerate() {
let _ = lookup.insert(c, u8::try_from(i).unwrap_or(0));
}
let bytes: Vec<u8> = s
.bytes()
.filter(|b| *b != b'=')
.filter_map(|b| lookup.get(&b).copied())
.collect();
let mut output = Vec::with_capacity(bytes.len() * 3 / 4);
let mut i = 0;
while i + 3 < bytes.len() {
output.push((bytes[i] << 2) | (bytes[i + 1] >> 4));
output.push((bytes[i + 1] << 4) | (bytes[i + 2] >> 2));
output.push((bytes[i + 2] << 6) | bytes[i + 3]);
i += 4;
}
if i + 2 < bytes.len() {
output.push((bytes[i] << 2) | (bytes[i + 1] >> 4));
output.push((bytes[i + 1] << 4) | (bytes[i + 2] >> 2));
} else if i + 1 < bytes.len() {
output.push((bytes[i] << 2) | (bytes[i + 1] >> 4));
}
output
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn text_content_converts() {
let raw = serde_json::json!({
"content": [{ "type": "text", "text": "hello world" }],
"isError": false
});
let output = convert_mcp_response_to_tool_output(raw).unwrap();
assert_eq!(output.content, "hello world");
assert_eq!(output.status, ToolResultStatus::Success);
}
#[test]
fn is_error_sets_failure_status() {
let raw = serde_json::json!({
"content": [{ "type": "text", "text": "oops" }],
"isError": true
});
let output = convert_mcp_response_to_tool_output(raw).unwrap();
assert_eq!(output.status, ToolResultStatus::Failure);
}
#[test]
fn audio_content_becomes_unsupported_text() {
let raw = serde_json::json!({
"content": [{ "type": "audio", "mimeType": "audio/wav", "data": "AAAA" }],
"isError": false
});
let output = convert_mcp_response_to_tool_output(raw).unwrap();
assert!(output.content.contains("unsupported audio content"));
}
#[test]
fn multiple_text_blocks_joined() {
let raw = serde_json::json!({
"content": [
{ "type": "text", "text": "line 1" },
{ "type": "text", "text": "line 2" }
],
"isError": false
});
let output = convert_mcp_response_to_tool_output(raw).unwrap();
assert!(output.content.contains("line 1"));
assert!(output.content.contains("line 2"));
}
}