use rho_sdk::tool::{ToolAsset, ToolError, ToolErrorKind};
use rmcp::model::{CallToolResult, ContentBlock, ResourceContents};
use base64::Engine;
pub(super) const MAX_RETAINED_IMAGE_BYTES: usize = 4 * 1024 * 1024;
const MAX_OUTPUT_SCHEMA_BYTES: usize = 64 * 1024;
const MAX_STRUCTURED_CONTENT_BYTES: usize = 256 * 1024;
const MAX_OUTPUT_SCHEMA_NODES: usize = 2_048;
const MAX_STRUCTURED_CONTENT_NODES: usize = 8_192;
#[derive(Debug, Default, PartialEq)]
pub(super) struct RenderedResult {
pub(super) text: String,
pub(super) assets: Vec<ToolAsset>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct ResultExpectation {
pub(super) output_schema: Option<serde_json::Value>,
}
#[derive(Default)]
struct AssetBudget {
retained_bytes: usize,
retained_images: usize,
}
pub(super) fn render(
result: &CallToolResult,
expectation: &ResultExpectation,
max_output_bytes: usize,
) -> Result<RenderedResult, ToolError> {
let failed = result.is_error.unwrap_or(false);
let mut rendered = RenderedResult::default();
let mut budget = AssetBudget::default();
let mut sections = Vec::new();
for block in &result.content {
if let Some(section) = render_block(block, &mut rendered.assets, &mut budget) {
sections.push(section);
}
}
if let Some(structured) = &result.structured_content {
if !failed {
if let Some(schema) = &expectation.output_schema {
validate_structured_content(schema, structured)?;
}
}
sections.retain(|section| !mirrors(section, structured));
sections.push(
serde_json::to_string_pretty(structured).unwrap_or_else(|_| structured.to_string()),
);
} else if expectation.output_schema.is_some() && !failed {
return Err(ToolError::new(
ToolErrorKind::Execution,
"MCP server declared an output schema but returned no structured content",
));
}
if sections.is_empty() {
sections.push("The MCP server returned no content.".into());
}
rendered.text = rho_tools::tool::truncate(sections.join("\n\n"), max_output_bytes);
if failed {
return Err(ToolError::new(ToolErrorKind::Execution, rendered.text));
}
Ok(rendered)
}
pub(super) fn render_prompt_messages(
messages: &[rmcp::model::PromptMessage],
max_output_bytes: usize,
) -> String {
let mut assets = Vec::new();
let mut budget = AssetBudget::default();
let sections = messages
.iter()
.filter_map(|message| {
let body = render_block(&message.content, &mut assets, &mut budget)?;
Some(match message.role {
rmcp::model::Role::User => body,
_ => format!("[assistant]\n{body}"),
})
})
.collect::<Vec<_>>();
rho_tools::tool::truncate(sections.join("\n\n"), max_output_bytes)
}
fn validate_structured_content(
schema: &serde_json::Value,
structured: &serde_json::Value,
) -> Result<(), ToolError> {
ensure_json_budget(schema, MAX_OUTPUT_SCHEMA_BYTES, "output schema")?;
ensure_json_budget(
structured,
MAX_STRUCTURED_CONTENT_BYTES,
"structured content",
)?;
ensure_node_budget(schema, MAX_OUTPUT_SCHEMA_NODES, "output schema")?;
ensure_node_budget(
structured,
MAX_STRUCTURED_CONTENT_NODES,
"structured content",
)?;
let validator = jsonschema::options()
.with_pattern_options(jsonschema::PatternOptions::regex())
.build(schema)
.map_err(|error| {
ToolError::new(
ToolErrorKind::Execution,
format!("MCP server declared an invalid output schema: {error}"),
)
})?;
if let Err(error) = validator.validate(structured) {
return Err(ToolError::new(
ToolErrorKind::Execution,
format!("MCP structured content failed the declared output schema: {error}"),
));
}
Ok(())
}
fn ensure_json_budget(
value: &serde_json::Value,
max_bytes: usize,
label: &str,
) -> Result<(), ToolError> {
let encoded = serde_json::to_vec(value).map_err(|error| {
ToolError::new(
ToolErrorKind::Execution,
format!("MCP server returned {label} that could not be measured: {error}"),
)
})?;
if encoded.len() > max_bytes {
return Err(ToolError::new(
ToolErrorKind::Execution,
format!(
"MCP server {label} exceeds the {} validation budget",
byte_size(max_bytes)
),
));
}
Ok(())
}
fn ensure_node_budget(
value: &serde_json::Value,
max_nodes: usize,
label: &str,
) -> Result<(), ToolError> {
let mut count = 0usize;
let mut stack = vec![value];
while let Some(next) = stack.pop() {
count = count.saturating_add(1);
if count > max_nodes {
return Err(ToolError::new(
ToolErrorKind::Execution,
format!("MCP server {label} exceeds the {max_nodes}-node validation budget"),
));
}
match next {
serde_json::Value::Array(items) => stack.extend(items.iter()),
serde_json::Value::Object(map) => stack.extend(map.values()),
_ => {}
}
}
Ok(())
}
fn mirrors(section: &str, structured: &serde_json::Value) -> bool {
let trimmed = section.trim();
serde_json::from_str::<serde_json::Value>(trimmed).is_ok_and(|parsed| &parsed == structured)
}
fn render_block(
block: &ContentBlock,
assets: &mut Vec<ToolAsset>,
budget: &mut AssetBudget,
) -> Option<String> {
match block {
ContentBlock::Text(text) => Some(text.text.clone()),
ContentBlock::Image(image) => Some(binary_section(
"image",
&image.mime_type,
&image.data,
assets,
budget,
)),
ContentBlock::Audio(audio) => Some(binary_section(
"audio",
&audio.mime_type,
&audio.data,
assets,
budget,
)),
ContentBlock::Resource(embedded) => Some(match &embedded.resource {
ResourceContents::TextResourceContents { uri, text, .. } => {
format!("[resource {uri}]\n{text}")
}
ResourceContents::BlobResourceContents {
uri,
mime_type,
blob,
..
} => {
let media_type = mime_type.as_deref().unwrap_or("application/octet-stream");
let descriptor = binary_section("resource", media_type, blob, assets, budget);
format!("[resource {uri}] {descriptor}")
}
_ => "[resource of a kind Rho does not render]".into(),
}),
ContentBlock::ResourceLink(link) => {
let mut line = format!("[resource link {} \"{}\"]", link.uri, link.name);
if let Some(description) = &link.description {
line.push_str(&format!("\n{description}"));
}
Some(line)
}
_ => Some("[content block of a kind Rho does not render]".into()),
}
}
fn binary_section(
label: &str,
media_type: &str,
encoded: &str,
assets: &mut Vec<ToolAsset>,
budget: &mut AssetBudget,
) -> String {
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
return format!("[{label} {media_type}, not valid base64]");
};
let size = bytes.len();
let descriptor = format!("[{label} {media_type}, {}]", byte_size(size));
if !media_type.starts_with("image/") {
return descriptor;
}
if budget.retained_images > 0 {
return format!("{descriptor} [not shown: card keeps only the first image]");
}
if size > MAX_RETAINED_IMAGE_BYTES {
return format!(
"{descriptor} [not retained: exceeds {} asset budget]",
byte_size(MAX_RETAINED_IMAGE_BYTES)
);
}
budget.retained_images += 1;
budget.retained_bytes = budget.retained_bytes.saturating_add(size);
assets.push(ToolAsset::new(media_type.to_string(), bytes));
descriptor
}
pub(crate) fn binary_descriptor(
label: &str,
media_type: &str,
decoded_size: Option<usize>,
) -> String {
match decoded_size {
Some(size) => format!("[{label} {media_type}, {}]", byte_size(size)),
None => format!("[{label} {media_type}, not valid base64]"),
}
}
fn byte_size(bytes: usize) -> String {
const UNIT: f64 = 1024.0;
let bytes = bytes as f64;
if bytes < UNIT {
return format!("{bytes:.0} B");
}
if bytes < UNIT * UNIT {
return format!("{:.1} KB", bytes / UNIT);
}
format!("{:.1} MB", bytes / (UNIT * UNIT))
}
#[cfg(test)]
#[path = "result_tests.rs"]
mod tests;