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
+69 -4
View File
@@ -4,7 +4,8 @@ use crate::mcp::{CatalogItem, CatalogItemKind, ConnectedServer, McpRegistry, Mcp
use anyhow::{Context, Result, anyhow};
use bm25::{Document, Language, SearchEngineBuilder};
use rmcp::model::{
CallToolRequestParams, CallToolResult, Prompt, Resource, ResourceTemplate, Tool,
CallToolRequestParams, CallToolResult, Prompt, ReadResourceRequestParams, ReadResourceResult,
Resource, ResourceTemplate, Tool,
};
use serde_json::{Value, json};
use std::collections::HashMap;
@@ -278,6 +279,18 @@ impl McpRuntime {
server_handle.call_tool(request).await.map_err(Into::into)
}
pub async fn read(&self, server: &str, uri: &str) -> Result<ReadResourceResult> {
let server_handle = self
.get(server)
.cloned()
.with_context(|| format!("Read MCP server does not exist: {server}"))?;
server_handle
.read_resource(ReadResourceRequestParams::new(uri))
.await
.map_err(Into::into)
}
}
fn catalog_key(item: &CatalogItem) -> String {
@@ -354,15 +367,30 @@ fn uri_template_variables(template: &str) -> Vec<String> {
#[cfg(test)]
pub(crate) mod test_fixtures {
use super::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use rmcp::model::{
ErrorData, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult,
ListToolsResult, PaginatedRequestParams, PromptArgument, PromptsCapability,
ResourcesCapability, ServerCapabilities, ServerInfo,
ReadResourceResponse, ResourceContents, ResourcesCapability, ServerCapabilities,
ServerInfo,
};
use rmcp::service::{RequestContext, RunningService};
use rmcp::{RoleServer, ServerHandler, ServiceExt};
use std::sync::atomic::{AtomicUsize, Ordering};
pub(crate) const FIXTURE_LOG_URI: &str = "file:///app.log";
pub(crate) const FIXTURE_LOG_TEXT: &str = "début of the log\n\
second line\n\
ERROR: disk full\n\
fourth line\n\
fifth line\n\
ERROR: café overheated\n\
seventh line\n\
eighth line";
pub(crate) const FIXTURE_BLOB_URI: &str = "file:///report.pdf";
pub(crate) const FIXTURE_BLOB_BYTES: &[u8] = &[0xff, 0xfe, 0x00, 0x88, 0x01];
#[derive(Clone)]
pub(crate) struct FixtureServer {
pub(crate) tools_capability: bool,
@@ -449,6 +477,41 @@ pub(crate) mod test_fixtures {
]))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, ErrorData> {
let uri = request.uri.as_str();
let contents = match uri {
FIXTURE_LOG_URI => vec![ResourceContents::text(FIXTURE_LOG_TEXT, uri)],
FIXTURE_BLOB_URI => vec![
ResourceContents::blob(STANDARD.encode(FIXTURE_BLOB_BYTES), uri)
.with_mime_type("application/pdf"),
],
"file:///multi" => vec![
ResourceContents::text("first", "file:///multi/0"),
ResourceContents::text("second", "file:///multi/1"),
ResourceContents::text("third", "file:///multi/2"),
],
"file:///huge" => (0..3)
.map(|i| {
ResourceContents::text("x".repeat(150 * 1024), format!("file:///huge/{i}"))
})
.collect(),
"file:///docs/readme" => vec![ResourceContents::text("readme body", uri)],
_ => {
return Err(ErrorData::resource_not_found(
format!("Unknown resource: {uri}"),
None,
));
}
};
Ok(ReadResourceResponse::Complete(ReadResourceResult::new(
contents,
)))
}
async fn list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
@@ -561,10 +624,11 @@ mod tests {
let mut functions = Functions::default();
functions.append_mcp_meta_functions(features);
assert_eq!(functions.declarations().len(), 3);
assert_eq!(functions.declarations().len(), 4);
assert!(functions.contains("mcp_invoke_fixture"));
assert!(functions.contains("mcp_search_fixture"));
assert!(functions.contains("mcp_describe_fixture"));
assert!(functions.contains("mcp_read_fixture"));
}
#[tokio::test]
@@ -589,10 +653,11 @@ mod tests {
let mut functions = Functions::default();
functions.append_mcp_meta_functions(features);
assert_eq!(functions.declarations().len(), 2);
assert_eq!(functions.declarations().len(), 3);
assert!(!functions.contains("mcp_invoke_fixture"));
assert!(functions.contains("mcp_search_fixture"));
assert!(functions.contains("mcp_describe_fixture"));
assert!(functions.contains("mcp_read_fixture"));
}
#[test]
+652 -6
View File
@@ -18,7 +18,7 @@ use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_META_FUNCTION_PREFIXES, MCP_PROMPT_META_FUNCTION_NAME_PREFIX,
MCP_READ_META_FUNCTION_NAME_PREFIX, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpServerFeatures,
McpServersConfig, is_mcp_meta_function,
McpServersConfig, is_mcp_meta_function, render,
};
use crate::parsers::{bash, python, typescript};
use anyhow::{Context, Result, anyhow, bail};
@@ -696,12 +696,70 @@ impl Functions {
},
);
let mut read_function_properties = IndexMap::new();
read_function_properties.insert(
"uri".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"Resource URI, or a resource template with {var} placeholders".into(),
),
..Default::default()
},
);
read_function_properties.insert(
"arguments".to_string(),
JsonSchema {
type_value: Some("object".to_string()),
description: Some("Template variable values (RFC 6570 Level 1 only)".into()),
..Default::default()
},
);
read_function_properties.insert(
"pattern".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"Optional regex; returns only matching lines (with context) from text content"
.into(),
),
..Default::default()
},
);
read_function_properties.insert(
"offset".to_string(),
JsonSchema {
type_value: Some("integer".to_string()),
description: Some(
"Byte offset for paging text. When pattern is set, offsets (and \
next_offset/total_bytes in the result) refer to the filtered stream, not \
the raw resource"
.into(),
),
default: Some(Value::from(0usize)),
..Default::default()
},
);
read_function_properties.insert(
"max_bytes".to_string(),
JsonSchema {
type_value: Some("integer".to_string()),
description: Some(format!(
"Max text bytes to return (clamped to {})",
render::TEXT_MAX_BYTES_CLAMP
)),
default: Some(Value::from(render::DEFAULT_TEXT_MAX_BYTES)),
..Default::default()
},
);
for features in mcp_servers {
let server = &features.name;
let search_function_name = format!("{}_{server}", MCP_SEARCH_META_FUNCTION_NAME_PREFIX);
let describe_function_name =
format!("{}_{server}", MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX);
let invoke_function_name = format!("{}_{server}", MCP_INVOKE_META_FUNCTION_NAME_PREFIX);
let read_function_name = format!("{}_{server}", MCP_READ_META_FUNCTION_NAME_PREFIX);
for prefix in gated_meta_function_prefixes(&features) {
match prefix {
MCP_INVOKE_META_FUNCTION_NAME_PREFIX => {
@@ -756,8 +814,27 @@ impl Functions {
agent: false,
});
}
MCP_READ_META_FUNCTION_NAME_PREFIX => {
self.declarations.push(FunctionDeclaration {
name: read_function_name.clone(),
description: formatdoc!(
r#"
Read a resource, or expand a resource template, from the {server} MCP server. Call
{describe_function_name} with kind "resource" or "resource_template" to find URIs and
template variables. Text content is paged via offset/max_bytes and can be filtered
with pattern; binary content is spilled to disk and its metadata returned.
"#
),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(read_function_properties.clone()),
required: Some(vec!["uri".to_string()]),
..Default::default()
},
agent: false,
});
}
// The declaration is added alongside its handler.
MCP_READ_META_FUNCTION_NAME_PREFIX => {}
MCP_PROMPT_META_FUNCTION_NAME_PREFIX => {}
_ => debug_assert!(false, "unhandled MCP meta-function prefix: {prefix}"),
}
@@ -1274,6 +1351,14 @@ impl ToolCall {
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
} else if cmd_name.starts_with(MCP_READ_META_FUNCTION_NAME_PREFIX) {
Self::read_mcp_resource(ctx, cmd_name, &json_data)
.await
.unwrap_or_else(|e| {
let error_msg = format!("MCP read failed: {e}");
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
} else {
Self::invoke_mcp_tool(ctx, cmd_name, &json_data)
.await
@@ -1335,6 +1420,15 @@ impl ToolCall {
json!({"tool_call_error": error_msg})
})
}
_ if cmd_name.starts_with(MCP_READ_META_FUNCTION_NAME_PREFIX) => {
Self::read_mcp_resource(ctx, &cmd_name, &json_data)
.await
.unwrap_or_else(|e| {
let error_msg = format!("MCP read failed: {e}");
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
_ if cmd_name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX) => {
Self::invoke_mcp_tool(ctx, &cmd_name, &json_data)
.await
@@ -1486,6 +1580,85 @@ impl ToolCall {
Ok(serde_json::to_value(result)?)
}
async fn read_mcp_resource(
ctx: &RequestContext,
cmd_name: &str,
json_data: &Value,
) -> Result<Value> {
let server = cmd_name
.strip_prefix(&format!("{MCP_READ_META_FUNCTION_NAME_PREFIX}_"))
.ok_or_else(|| anyhow!("Malformed MCP read function name: {cmd_name}"))?;
let uri = json_data
.get("uri")
.ok_or_else(|| anyhow!("Missing 'uri' in arguments"))?
.as_str()
.ok_or_else(|| anyhow!("Invalid 'uri' in arguments"))?;
let pattern = match json_data.get("pattern") {
Some(value) => Some(
value
.as_str()
.ok_or_else(|| anyhow!("Invalid 'pattern' in arguments"))?,
),
None => None,
};
let offset = match json_data.get("offset") {
Some(value) => value
.as_u64()
.ok_or_else(|| anyhow!("Invalid 'offset' in arguments"))?
as usize,
None => 0,
};
let max_bytes = match json_data.get("max_bytes") {
Some(value) => Some(
value
.as_u64()
.ok_or_else(|| anyhow!("Invalid 'max_bytes' in arguments"))?
as usize,
),
None => None,
};
let uri = match json_data.get("arguments").and_then(Value::as_object) {
Some(args) if !args.is_empty() => expand_uri_template(uri, args)?,
_ => uri.to_string(),
};
let result = ctx.tool_scope.mcp_runtime.read(server, &uri).await?;
let items: Vec<Value> = result
.contents
.iter()
.map(serde_json::to_value)
.collect::<Result<_, _>>()?;
let mut rendered_items = Vec::with_capacity(items.len());
let mut total_size = 0usize;
for (index, item) in items.iter().enumerate() {
let rendered = render_resource_content(item, pattern, offset, max_bytes, server)?;
let size = rendered.to_string().len();
// Bound the overall response; the first item is always included.
if index > 0 && total_size + size > render::TEXT_MAX_BYTES_CLAMP {
let omitted = items.len() - index;
rendered_items.push(json!({
"truncated": true,
"omitted_items": omitted,
"note": format!(
"{omitted} content item(s) omitted: the combined response would exceed \
{} bytes",
render::TEXT_MAX_BYTES_CLAMP
),
}));
break;
}
total_size += size;
rendered_items.push(rendered);
}
if rendered_items.len() == 1 {
Ok(rendered_items.remove(0))
} else {
Ok(Value::Array(rendered_items))
}
}
fn extract_call_config_from_agent(
&self,
functions: &Functions,
@@ -1529,6 +1702,146 @@ impl ToolCall {
}
}
fn expand_uri_template(template: &str, args: &serde_json::Map<String, Value>) -> Result<String> {
let mut expanded = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find('{') {
expanded.push_str(&rest[..start]);
let after = &rest[start + 1..];
let Some(len) = after.find('}') else {
bail!("Unclosed '{{' in URI template: {template}");
};
expanded.push_str(&expand_uri_template_variable(&after[..len], args)?);
rest = &after[len + 1..];
}
expanded.push_str(rest);
Ok(expanded)
}
fn expand_uri_template_variable(
expr: &str,
args: &serde_json::Map<String, Value>,
) -> Result<String> {
const LEVEL_1_ONLY: &str = "only RFC 6570 Level 1 simple substitution {var} is supported";
if let Some(operator) = expr.chars().next().filter(|c| "+#./;?&".contains(*c)) {
let name = match operator {
'+' => "reserved-expansion",
'#' => "fragment-expansion",
'.' => "label-expansion",
'/' => "path-segment-expansion",
';' => "path-style-parameter-expansion",
'?' => "form-style-query-expansion",
_ => "form-style-query-continuation",
};
bail!(
"The '{operator}' {name} operator in '{{{expr}}}' requires RFC 6570 Level 2 or \
higher; {LEVEL_1_ONLY}"
);
}
if expr.contains(',') {
bail!(
"The ',' multi-variable expression '{{{expr}}}' requires RFC 6570 Level 3; {LEVEL_1_ONLY}"
);
}
if expr.contains(':') {
bail!("The ':' prefix modifier in '{{{expr}}}' requires RFC 6570 Level 4; {LEVEL_1_ONLY}");
}
if expr.ends_with('*') {
bail!("The '*' explode modifier in '{{{expr}}}' requires RFC 6570 Level 4; {LEVEL_1_ONLY}");
}
if expr.is_empty()
|| !expr
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
{
bail!("Invalid variable name '{{{expr}}}' in URI template; expected [A-Za-z0-9_.]+");
}
let value = args
.get(expr)
.ok_or_else(|| anyhow!("URI template variable '{expr}' is missing from 'arguments'"))?;
let text = match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
Value::Bool(boolean) => boolean.to_string(),
other => bail!(
"URI template variable '{expr}' must be a string, number, or boolean; got {other}"
),
};
Ok(urlencoding::encode(&text).into_owned())
}
#[derive(Debug)]
enum ResourceContentBody {
Text(String),
Blob(String),
}
// rmcp's untagged ResourceContents enum cannot represent malformed items
// (both or neither of text/blob), so classification happens on the raw Value.
fn parse_resource_content(item: &Value) -> Result<ResourceContentBody> {
let text = item.get("text");
let blob = item.get("blob");
match (text, blob) {
(Some(text), None) => Ok(ResourceContentBody::Text(
text.as_str()
.ok_or_else(|| anyhow!("Resource content 'text' is not a string"))?
.to_string(),
)),
(None, Some(blob)) => Ok(ResourceContentBody::Blob(
blob.as_str()
.ok_or_else(|| anyhow!("Resource content 'blob' is not a string"))?
.to_string(),
)),
(Some(_), Some(_)) => {
bail!("Resource content item has both 'text' and 'blob'; expected exactly one")
}
(None, None) => {
bail!("Resource content item has neither 'text' nor 'blob'; expected exactly one")
}
}
}
fn render_resource_content(
item: &Value,
pattern: Option<&str>,
offset: usize,
max_bytes: Option<usize>,
server: &str,
) -> Result<Value> {
let uri = item.get("uri").and_then(Value::as_str);
let mime_type = item.get("mimeType").and_then(Value::as_str);
let text = match parse_resource_content(item)? {
ResourceContentBody::Text(text) => text,
ResourceContentBody::Blob(blob) => match render::render_blob(&blob, mime_type, server)? {
render::RenderedBlob::Text(text) => text,
render::RenderedBlob::Spilled(meta) => {
let mut value = serde_json::to_value(meta)?;
if let Some(map) = value.as_object_mut() {
map.insert("uri".to_string(), json!(uri));
}
return Ok(value);
}
},
};
let rendered = render::render_text(&text, pattern, offset, max_bytes)?;
let mut value = json!({
"uri": uri,
"mime_type": mime_type,
"text": rendered.text,
"truncated": rendered.truncated,
"total_bytes": rendered.total_bytes,
"next_offset": rendered.next_offset,
});
if let Some(next_offset) = rendered.next_offset {
value["note"] = json!(format!(
"Content truncated; re-call with offset={next_offset} to continue (max_bytes is \
clamped to {})",
render::TEXT_MAX_BYTES_CLAMP
));
}
Ok(value)
}
pub fn run_llm_function(
cmd_name: String,
cmd_args: Vec<String>,
@@ -1872,10 +2185,14 @@ fn format_json_colored_keys(value: &serde_json::Value) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
use crate::config::test_fixtures::{
FIXTURE_BLOB_BYTES, FIXTURE_BLOB_URI, FIXTURE_LOG_TEXT, FIXTURE_LOG_URI, FixtureServer,
fixture_runtime,
};
use crate::config::{AppState, WorkingMode};
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
use serde_json::json;
use serial_test::serial;
use std::sync::Arc;
fn call(name: &str, id: Option<&str>) -> ToolCall {
@@ -2298,20 +2615,22 @@ mod tests {
fn functions_append_mcp_meta_resources_only_omits_invoke() {
let mut f = Functions::default();
f.append_mcp_meta_functions(vec![mcp_features("res", false, true, false)]);
assert_eq!(f.declarations().len(), 2);
assert_eq!(f.declarations().len(), 3);
assert!(!f.contains("mcp_invoke_res"));
assert!(f.contains("mcp_search_res"));
assert!(f.contains("mcp_describe_res"));
assert!(f.contains("mcp_read_res"));
}
#[test]
fn functions_append_mcp_meta_all_capabilities_emits_three() {
fn functions_append_mcp_meta_all_capabilities_emits_four() {
let mut f = Functions::default();
f.append_mcp_meta_functions(vec![mcp_features("srv", true, true, true)]);
assert_eq!(f.declarations().len(), 3);
assert_eq!(f.declarations().len(), 4);
assert!(f.contains("mcp_invoke_srv"));
assert!(f.contains("mcp_search_srv"));
assert!(f.contains("mcp_describe_srv"));
assert!(f.contains("mcp_read_srv"));
}
#[test]
@@ -2517,6 +2836,333 @@ mod tests {
);
}
fn template_args(pairs: &[(&str, Value)]) -> serde_json::Map<String, Value> {
pairs
.iter()
.map(|(key, value)| (key.to_string(), value.clone()))
.collect()
}
fn resources_fixture() -> FixtureServer {
FixtureServer {
resources_capability: true,
..Default::default()
}
}
async fn eval_mcp_read(args: Value) -> Result<Value> {
let (runtime, _server) = fixture_runtime(resources_fixture()).await;
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
ctx.tool_scope.mcp_runtime = runtime;
call_with_args("mcp_read_fixture", args)
.eval_mcp(&ctx)
.await
}
#[test]
fn expand_uri_template_substitutes_simple_vars() {
let args = template_args(&[("path", json!("docs")), ("name", json!("readme"))]);
assert_eq!(
expand_uri_template("file:///{path}/{name}", &args).unwrap(),
"file:///docs/readme"
);
}
#[test]
fn expand_uri_template_stringifies_numbers_and_bools() {
let args = template_args(&[("id", json!(42)), ("flag", json!(true))]);
assert_eq!(
expand_uri_template("item://{id}/{flag}", &args).unwrap(),
"item://42/true"
);
}
#[test]
fn expand_uri_template_percent_encodes_values() {
let args = template_args(&[("q", json!("a b/c✓"))]);
assert_eq!(
expand_uri_template("search://{q}", &args).unwrap(),
"search://a%20b%2Fc%E2%9C%93"
);
}
#[test]
fn expand_uri_template_without_placeholders_is_noop() {
let args = template_args(&[("unused", json!("x"))]);
assert_eq!(
expand_uri_template("file:///static", &args).unwrap(),
"file:///static"
);
}
#[test]
fn expand_uri_template_missing_variable_names_it() {
let err = expand_uri_template("file:///{path}", &template_args(&[]))
.unwrap_err()
.to_string();
assert!(err.contains("'path'"), "{err}");
assert!(err.contains("missing"), "{err}");
}
#[test]
fn expand_uri_template_rejects_higher_level_operators() {
let args = template_args(&[("var", json!("v"))]);
for operator in ["+", "#", ".", "/", ";", "?", "&"] {
let err = expand_uri_template(&format!("x://{{{operator}var}}"), &args)
.unwrap_err()
.to_string();
assert!(err.contains(&format!("'{operator}'")), "{err}");
assert!(err.contains("Level 1 simple substitution"), "{err}");
}
}
#[test]
fn expand_uri_template_rejects_modifiers_and_multi_vars() {
let args = template_args(&[("a", json!("v")), ("b", json!("w")), ("var", json!("v"))]);
for (template, construct) in [
("x://{var*}", "'*' explode modifier"),
("x://{var:3}", "':' prefix modifier"),
("x://{a,b}", "',' multi-variable"),
] {
let err = expand_uri_template(template, &args)
.unwrap_err()
.to_string();
assert!(err.contains(construct), "{err}");
assert!(err.contains("Level 1 simple substitution"), "{err}");
}
}
#[test]
fn expand_uri_template_unclosed_brace_errors() {
let err = expand_uri_template("file:///{path", &template_args(&[]))
.unwrap_err()
.to_string();
assert!(err.contains("Unclosed"), "{err}");
}
#[test]
fn expand_uri_template_rejects_invalid_variable_names() {
let err = expand_uri_template("x://{va r}", &template_args(&[]))
.unwrap_err()
.to_string();
assert!(err.contains("Invalid variable name"), "{err}");
}
#[test]
fn expand_uri_template_rejects_non_scalar_values() {
for value in [json!(null), json!(["a"]), json!({"k": "v"})] {
let args = template_args(&[("v", value)]);
let err = expand_uri_template("x://{v}", &args)
.unwrap_err()
.to_string();
assert!(err.contains("string, number, or boolean"), "{err}");
}
}
#[test]
fn parse_resource_content_classifies_by_field_presence() {
assert!(matches!(
parse_resource_content(&json!({"uri": "u", "text": "hi"})).unwrap(),
ResourceContentBody::Text(text) if text == "hi"
));
assert!(matches!(
parse_resource_content(&json!({"uri": "u", "blob": "aGk="})).unwrap(),
ResourceContentBody::Blob(blob) if blob == "aGk="
));
}
#[test]
fn parse_resource_content_rejects_both_and_neither() {
let err = parse_resource_content(&json!({"text": "t", "blob": "b"}))
.unwrap_err()
.to_string();
assert!(err.contains("both"), "{err}");
let err = parse_resource_content(&json!({"uri": "u"}))
.unwrap_err()
.to_string();
assert!(err.contains("neither"), "{err}");
}
#[test]
fn functions_mcp_read_declaration_has_paging_params() {
let mut f = Functions::default();
f.append_mcp_meta_functions(vec![mcp_features("srv", false, true, false)]);
let decl = f.find("mcp_read_srv").unwrap();
let props = decl.parameters.properties.as_ref().unwrap();
for param in ["uri", "arguments", "pattern", "offset", "max_bytes"] {
assert!(props.contains_key(param), "missing {param}");
}
assert_eq!(props.len(), 5);
assert_eq!(props["offset"].default, Some(Value::from(0usize)));
assert_eq!(
props["max_bytes"].default,
Some(Value::from(render::DEFAULT_TEXT_MAX_BYTES))
);
assert_eq!(decl.parameters.required, Some(vec!["uri".to_string()]));
}
#[test]
fn mcp_read_routes_through_the_concurrent_mcp_path() {
assert!(is_mcp_meta_function("mcp_read_x"));
}
#[test]
fn eval_mcp_read_returns_rendered_text() {
let output = run_async(eval_mcp_read(json!({"uri": FIXTURE_LOG_URI}))).unwrap();
assert_eq!(output["uri"], FIXTURE_LOG_URI);
assert_eq!(output["mime_type"], "text/plain");
assert_eq!(output["text"], FIXTURE_LOG_TEXT);
assert_eq!(output["truncated"], false);
assert_eq!(output["next_offset"], Value::Null);
}
#[test]
fn eval_routes_mcp_read_to_resource_handler() {
let output = run_async(async {
let (runtime, _server) = fixture_runtime(resources_fixture()).await;
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
ctx.tool_scope
.functions
.append_mcp_meta_functions(vec![mcp_features("fixture", true, true, false)]);
ctx.tool_scope.mcp_runtime = runtime;
let call = call_with_args("mcp_read_fixture", json!({"uri": FIXTURE_LOG_URI}));
call.eval(&mut ctx).await
})
.unwrap();
assert_eq!(output["text"], FIXTURE_LOG_TEXT);
}
#[test]
fn eval_mcp_read_pages_text_with_offset() {
let (page1, page2) = run_async(async {
let (runtime, _server) = fixture_runtime(resources_fixture()).await;
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
ctx.tool_scope.mcp_runtime = runtime;
let call = call_with_args(
"mcp_read_fixture",
json!({"uri": FIXTURE_LOG_URI, "max_bytes": 20}),
);
let page1 = call.eval_mcp(&ctx).await.unwrap();
let call = call_with_args(
"mcp_read_fixture",
json!({"uri": FIXTURE_LOG_URI, "offset": page1["next_offset"], "max_bytes": 20}),
);
let page2 = call.eval_mcp(&ctx).await.unwrap();
(page1, page2)
});
assert_eq!(page1["truncated"], true);
assert!(page1["note"].as_str().unwrap().contains("204800"));
let text1 = page1["text"].as_str().unwrap();
let text2 = page2["text"].as_str().unwrap();
assert!(!text2.is_empty());
assert!(FIXTURE_LOG_TEXT.starts_with(&format!("{text1}{text2}")));
}
#[test]
fn eval_mcp_read_pattern_filters_lines_with_context() {
let output = run_async(eval_mcp_read(
json!({"uri": FIXTURE_LOG_URI, "pattern": "café"}),
))
.unwrap();
let text = output["text"].as_str().unwrap();
assert!(text.contains("6:ERROR: café overheated"), "{text}");
assert!(text.contains("4-fourth line"), "{text}");
assert!(!text.contains("disk full"), "{text}");
assert_eq!(output["total_bytes"], text.len());
}
#[test]
fn eval_mcp_read_invalid_pattern_returns_teaching_error() {
let output = run_async(eval_mcp_read(
json!({"uri": FIXTURE_LOG_URI, "pattern": "("}),
))
.unwrap();
let err = output["tool_call_error"].as_str().unwrap();
assert!(err.contains("Invalid filter pattern"), "{err}");
}
#[test]
#[serial]
fn eval_mcp_read_blob_spills_with_metadata() {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let cache_dir = env::temp_dir().join(format!(
"coyote-read-blob-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&cache_dir).unwrap();
let env_name = get_env_name("cache_dir");
let previous = env::var_os(&env_name);
unsafe { env::set_var(&env_name, &cache_dir) };
let output = run_async(eval_mcp_read(json!({"uri": FIXTURE_BLOB_URI}))).unwrap();
unsafe {
match previous {
Some(value) => env::set_var(&env_name, value),
None => env::remove_var(&env_name),
}
}
assert_eq!(output["spilled"], true);
assert_eq!(output["uri"], FIXTURE_BLOB_URI);
assert_eq!(output["mime_type"], "application/pdf");
assert_eq!(output["sha256"].as_str().unwrap().len(), 64);
let path = PathBuf::from(output["path"].as_str().unwrap());
assert!(path.starts_with(&cache_dir));
assert_eq!(fs::read(&path).unwrap(), FIXTURE_BLOB_BYTES);
fs::remove_dir_all(&cache_dir).unwrap();
}
#[test]
fn eval_mcp_read_multi_content_returns_array() {
let output = run_async(eval_mcp_read(json!({"uri": "file:///multi"}))).unwrap();
let items = output.as_array().unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0]["text"], "first");
assert_eq!(items[1]["text"], "second");
assert_eq!(items[2]["text"], "third");
assert_eq!(items[1]["uri"], "file:///multi/1");
}
#[test]
fn eval_mcp_read_multi_content_enforces_overall_ceiling() {
let output = run_async(eval_mcp_read(
json!({"uri": "file:///huge", "max_bytes": render::TEXT_MAX_BYTES_CLAMP}),
))
.unwrap();
let items = output.as_array().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0]["text"].as_str().unwrap().len(), 150 * 1024);
let marker = &items[1];
assert_eq!(marker["truncated"], true);
assert_eq!(marker["omitted_items"], 2);
let note = marker["note"].as_str().unwrap();
assert!(note.contains("2 content item(s) omitted"), "{note}");
assert!(note.contains("204800"), "{note}");
}
#[test]
fn eval_mcp_read_expands_template_end_to_end() {
let output = run_async(eval_mcp_read(json!({
"uri": "file:///{path}/{name}",
"arguments": {"path": "docs", "name": "readme"},
})))
.unwrap();
assert_eq!(output["uri"], "file:///docs/readme");
assert_eq!(output["text"], "readme body");
}
#[test]
fn functions_supervisor_includes_task_queue_tools() {
let mut f = Functions::default();
+2 -1
View File
@@ -1526,9 +1526,10 @@ mod tests {
sync_agent_functions_to_ctx(&mut ctx).unwrap();
let functions = &ctx.tool_scope.functions;
assert_eq!(functions.declarations().len(), 2);
assert_eq!(functions.declarations().len(), 3);
assert!(functions.contains("mcp_search_fixture"));
assert!(functions.contains("mcp_describe_fixture"));
assert!(functions.contains("mcp_read_fixture"));
assert!(!functions.contains("mcp_invoke_fixture"));
}
+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();