diff --git a/src/config/tool_scope.rs b/src/config/tool_scope.rs index 4ca9d29..eaac7aa 100644 --- a/src/config/tool_scope.rs +++ b/src/config/tool_scope.rs @@ -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 { + 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 { #[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, + ) -> Result { + 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, @@ -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] diff --git a/src/function/mod.rs b/src/function/mod.rs index 5ea626b..47ff850 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -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 { + 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 = result + .contents + .iter() + .map(serde_json::to_value) + .collect::>()?; + + 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) -> Result { + 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, +) -> Result { + 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 { + 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, + server: &str, +) -> Result { + 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, @@ -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 { + 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 { + 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(); diff --git a/src/function/supervisor.rs b/src/function/supervisor.rs index ab1e7c1..32c5d48 100644 --- a/src/function/supervisor.rs +++ b/src/function/supervisor.rs @@ -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")); } diff --git a/src/mcp/render.rs b/src/mcp/render.rs index 771e4fc..15ab4e1 100644 --- a/src/mcp/render.rs +++ b/src/mcp/render.rs @@ -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();