feat(mcp): add mcp_read meta-tool for resource reads

Implements plans/mcp-resources-prompts-design.md section 4.3 (T4):
mcp_read_<server> declaration and handler wired to render.rs, RFC 6570
Level-1-only URI template expansion, defensive ResourceContents parsing,
per-item text paging with pattern filtering, blob spill metadata, an
overall 204800-byte multi-content ceiling, dispatch wiring on both
eval chains, and a render_text paging-stall guard.
This commit is contained in:
2026-08-25 11:37:41 -06:00
parent 437512fd6d
commit 67819784b7
4 changed files with 742 additions and 13 deletions
+19 -2
View File
@@ -1,8 +1,6 @@
//! Content policy for MCP resource and tool content: UTF-8-boundary-safe text
//! paging, grep-style pattern filtering, and spill-to-disk for binary blobs.
#![allow(dead_code)]
use crate::config::paths;
use base64::engine::general_purpose::STANDARD;
use base64::read::DecoderReader;
@@ -136,6 +134,14 @@ pub fn render_text(
while !stream.is_char_boundary(end) {
end -= 1;
}
// A max_bytes smaller than one codepoint would produce an empty page with
// next_offset == offset, stalling paging; always advance by at least one.
if end == start && start < total_bytes {
end += 1;
while !stream.is_char_boundary(end) {
end += 1;
}
}
let truncated = end < total_bytes;
Ok(RenderedText {
text: stream[start..end].to_string(),
@@ -428,6 +434,17 @@ mod tests {
assert!(!rest.truncated);
}
#[test]
fn max_bytes_below_one_codepoint_still_advances() {
// 'é' is 2 bytes; max_bytes 1 must not stall at next_offset == offset.
let rendered = render_text("éa", None, 0, Some(1)).unwrap();
assert_eq!(rendered.text, "é");
assert!(rendered.truncated);
assert_eq!(rendered.total_bytes, 3);
assert_eq!(rendered.next_offset, Some(2));
}
#[test]
fn offset_past_eof_returns_empty() {
let rendered = render_text("short", None, 100, None).unwrap();