Account for JSON overhead when truncating MCP tool results (#50470)

## Why

Truncating the preview alone can leave the serialized MCP tool result above its byte budget because JSON escaping and the result wrapper add bytes.

## What changed

Measure the complete serialized replacement and progressively reduce the preview budget until the result fits or the preview is empty.

## Testing

Strengthen the regression test with quotes, backslashes, and newlines. Assert that the serialized result fits within the 1,024-byte budget and that truncating it again returns the existing result unchanged.

GitOrigin-RevId: ebd97b1a6ca71343941a96e472cb313028ac680c
This commit is contained in:
Owen Lin
2026-10-03 00:13:58 +00:00
committed by copyberry
parent f5a2272c6c
commit be48ae396e
2 changed files with 41 additions and 14 deletions
+33 -11
View File
@@ -40,8 +40,8 @@ pub fn truncate_text(content: &str, policy: TruncationPolicy) -> String {
}
}
/// Replaces an oversized serialized MCP result with a text preview while preserving its error
/// status and supported bounded metadata.
/// Replaces an oversized serialized MCP result with a bounded text preview while preserving its
/// error status and supported bounded metadata.
pub fn truncate_mcp_tool_result(
result: &CallToolResult,
max_bytes: usize,
@@ -53,15 +53,37 @@ pub fn truncate_mcp_tool_result(
return Cow::Borrowed(result);
}
Cow::Owned(CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": truncate_text(&serialized, TruncationPolicy::Bytes(max_bytes)),
})],
structured_content: None,
is_error: result.is_error,
meta: None,
})
let meta = None;
let mut preview_budget = max_bytes;
loop {
let preview = if preview_budget == 0 {
String::new()
} else {
truncate_text(&serialized, TruncationPolicy::Bytes(preview_budget))
};
let truncated = CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": preview,
})],
structured_content: None,
is_error: result.is_error,
meta: meta.clone(),
};
let Ok(truncated_serialized) = serde_json::to_string(&truncated) else {
return Cow::Borrowed(result);
};
let truncated_len = truncated_serialized.len();
if truncated_len <= max_bytes || preview_budget == 0 {
return Cow::Owned(truncated);
}
// The preview is serialized as a JSON string, so escaping can make it exceed its raw byte
// budget. Scale the next budget by the observed size of the complete replacement.
preview_budget = (preview_budget.saturating_mul(max_bytes) / truncated_len)
.min(preview_budget.saturating_sub(1));
}
}
/// Applies the existing byte/token budget without changing success metadata or media ordering.
@@ -32,10 +32,11 @@ fn truncate_mcp_tool_result_borrows_small_result() {
#[test]
fn truncate_mcp_tool_result_compacts_large_result() {
let max_bytes = 1024;
let result = CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": format!("head\n{}\ntail", "x".repeat(5_000)),
"text": format!("head\n{}\ntail", "\"\\\n".repeat(5_000)),
})],
structured_content: Some(serde_json::json!({"x": "y".repeat(5_000)})),
is_error: Some(true),
@@ -44,7 +45,7 @@ fn truncate_mcp_tool_result_compacts_large_result() {
})),
};
let truncated = truncate_mcp_tool_result(&result, /*max_bytes*/ 1024).into_owned();
let truncated = truncate_mcp_tool_result(&result, max_bytes).into_owned();
let preview = truncated.content[0]["text"]
.as_str()
@@ -61,10 +62,14 @@ fn truncate_mcp_tool_result_compacts_large_result() {
meta: None,
}
);
assert!(preview.len() < 2 * 1024);
assert!(serde_json::to_string(&truncated).unwrap().len() <= max_bytes);
assert!(preview.contains("head"));
assert!(preview.contains("chars truncated"));
assert!(preview.contains("meta-tail"));
let truncated_again = truncate_mcp_tool_result(&truncated, max_bytes);
assert!(matches!(truncated_again, std::borrow::Cow::Borrowed(_)));
assert_eq!(truncated_again.as_ref(), &truncated);
}
#[test]