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:
@@ -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("aé", 2), "a");
|
||||
assert_eq!(truncate_utf8("aé", 3), "aé");
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user