feat(mcp): bound tool-result passthrough and surface resource audience annotations

Route CallToolResult content through the render.rs content policy per
plans/mcp-resources-prompts-design.md §6 (T8): oversized text sliced at
TEXT_MAX_BYTES_CLAMP with a self-explaining truncation note, image/audio/
embedded blob content spilled (or inlined when UTF-8-clean) instead of
shipping base64 into model context, and structuredContent subject to the
same ceiling. Clamp server-controlled uri/mime metadata strings to the new
METADATA_MAX_BYTES bound in both the read and tool-result paths, sanitize
the terminal rendering of MCP dispatch errors while keeping raw text in
the tool_call_error payload, and surface resource audience annotations in
both mcp_search results and mcp_read metadata via the catalog.
This commit is contained in:
2026-08-25 11:37:41 -06:00
parent 6fade71e8c
commit eb37f8bb46
4 changed files with 562 additions and 33 deletions
+47
View File
@@ -23,6 +23,8 @@ pub const TEXT_MAX_BYTES_CLAMP: usize = 204_800;
pub const BLOB_DECODE_CEILING_BYTES: usize = 50 * 1024 * 1024;
/// Total size bound for the spill tree; oldest files are evicted beyond it.
pub const SPILL_DIR_MAX_BYTES: u64 = 512 * 1024 * 1024;
/// Byte bound on server-supplied metadata strings (uri, mime type) copied into output.
pub const METADATA_MAX_BYTES: usize = 4096;
const PATTERN_CONTEXT_LINES: usize = 2;
const HUNK_SEPARATOR: &str = "--";
@@ -200,6 +202,29 @@ pub fn render_blob_at(
}))
}
/// Truncates `text` to at most `max_bytes`, rounding the cut point back to a
/// UTF-8 character boundary.
pub fn truncate_utf8(text: &str, max_bytes: usize) -> &str {
if text.len() <= max_bytes {
return text;
}
let mut end = max_bytes;
while !text.is_char_boundary(end) {
end -= 1;
}
&text[..end]
}
/// Bounds a server-supplied metadata string to [`METADATA_MAX_BYTES`],
/// appending a marker citing the constant when the input is truncated.
pub fn clamp_metadata(text: &str) -> String {
if text.len() <= METADATA_MAX_BYTES {
return text.to_string();
}
let clamped = truncate_utf8(text, METADATA_MAX_BYTES);
format!("{clamped} [truncated: exceeds METADATA_MAX_BYTES ({METADATA_MAX_BYTES} bytes)]")
}
fn filter_lines(text: &str, pattern: &str) -> Result<String, RenderError> {
let regex = Regex::new(pattern).map_err(|error| RenderError::InvalidPattern {
pattern: pattern.to_string(),
@@ -486,6 +511,28 @@ mod tests {
assert_eq!(rendered.next_offset, Some(TEXT_MAX_BYTES_CLAMP));
}
#[test]
fn truncate_utf8_rounds_back_to_char_boundary() {
// 'é' occupies bytes 1..3; a cut at byte 2 lands inside it.
assert_eq!(truncate_utf8("", 2), "a");
assert_eq!(truncate_utf8("", 3), "");
assert_eq!(truncate_utf8("abc", 10), "abc");
assert_eq!(truncate_utf8("abc", 0), "");
}
#[test]
fn clamp_metadata_appends_marker_only_when_oversized() {
assert_eq!(clamp_metadata("text/plain"), "text/plain");
let long = "u".repeat(METADATA_MAX_BYTES + 1);
let clamped = clamp_metadata(&long);
assert!(clamped.starts_with(&"u".repeat(METADATA_MAX_BYTES)));
assert!(clamped.contains("METADATA_MAX_BYTES"));
assert!(clamped.contains(&METADATA_MAX_BYTES.to_string()));
}
#[test]
fn pattern_emits_matches_with_context_and_line_numbers() {
let rendered = render_text(TEN_LINES, Some("^five$"), 0, None).unwrap();