//! `view_image` MCP tool — load an image from a path, http(s) URL, or //! `data:` URL and return it as an MCP `image` content block that any //! multimodal-capable host (Anthropic, OpenAI-compatible, etc.) can forward //! to its model. //! //! Design goals: tiny surface, no protocol-specific branching, and a //! "reasonable resolution" that fits comfortably inside both Anthropic's //! recommended ≤1568px / ≤5 MiB image budget and OpenAI's high-detail tile //! size sweet spot. The MCP host translates `Content::image(data, mime)` //! into the right provider-native shape on our behalf (see Goose's //! `providers::utils::convert_image` for a reference implementation). use crate::paths::resolve_path; use crate::shell::SharedState; use base64::Engine; use image::{ codecs::{jpeg::JpegEncoder, png::PngEncoder}, DynamicImage, ExtendedColorType, ImageEncoder, ImageReader, Limits, }; use rmcp::{ model::{CallToolResult, Content}, ErrorData, }; use schemars::JsonSchema; use serde::Deserialize; use std::io::Cursor; use std::path::PathBuf; use std::time::Duration; /// Hard cap on bytes we will read from disk / URL / data: URL. pub(crate) const MAX_SOURCE_BYTES: usize = 20 * 1024 * 1024; /// Hard cap on the raw (pre-base64) bytes we emit. base64 expands by 4/3, so /// a 3 MiB raw payload becomes ~4 MiB on the wire — comfortably below /// Anthropic's 5 MiB-per-image limit. pub(crate) const MAX_FINAL_RAW_BYTES: usize = 3 * 1024 * 1024; /// Default longest-edge cap. Matches Anthropic's published recommendation /// (≤1568px) and lands well inside OpenAI's high-detail tile budget. pub(crate) const DEFAULT_MAX_DIM: u32 = 1568; pub(crate) const MIN_MAX_DIM: u32 = 64; pub(crate) const MAX_MAX_DIM: u32 = 2048; /// Hard cap on decoded pixel count. A ≤20 MiB compressed source can decode /// to hundreds of megabytes; we reject anything above this budget *before* /// touching the decoder. 64 megapixels is generous (e.g. 8000×8000) yet /// keeps worst-case allocation well under a gigabyte. pub(crate) const MAX_PIXELS: u64 = 64 * 1024 * 1024; /// Defence-in-depth for the `image` decoder: bound any single allocation it /// performs to 256 MiB (the default is 512 MiB and skews high for a dev MCP). pub(crate) const MAX_DECODER_ALLOC: u64 = 256 * 1024 * 1024; /// Connect + read timeout for URL fetches. const FETCH_TIMEOUT: Duration = Duration::from_secs(10); /// Build the decoder allocation cap. Centralised so the resize path uses the /// same value tests can reason about. fn decode_limits() -> Limits { let mut l = Limits::default(); l.max_alloc = Some(MAX_DECODER_ALLOC); l } #[derive(Debug, Deserialize, JsonSchema)] pub struct ViewImageParams { /// Image source: an absolute or workspace-relative file path, /// an `http://` / `https://` URL, or a `data:image/;base64,...` URL. pub source: String, /// Optional longest-edge cap in pixels. Clamped to [64, 2048]. /// Defaults to 1568, which fits Anthropic's recommended budget and /// OpenAI's high-detail tile size. #[serde(default)] pub max_dim: Option, /// Workspace root for relative path resolution. Ignored for URL sources. /// Defaults to the server's cwd. #[serde(default)] pub workdir: Option, } /// What `view_image` returns: the (mime, raw bytes) we will base64-encode /// into an MCP image content block, plus a short human-readable summary. #[derive(Debug)] struct PreparedImage { mime: &'static str, bytes: Vec, summary: String, } pub async fn run(state: &SharedState, p: ViewImageParams) -> Result { let max_dim = p .max_dim .unwrap_or(DEFAULT_MAX_DIM) .clamp(MIN_MAX_DIM, MAX_MAX_DIM); let (raw, source_label) = load_source(state, &p).await?; let prepared = prepare(&raw, max_dim).map_err(invalid_params)?; let encoded = base64::engine::general_purpose::STANDARD.encode(&prepared.bytes); let header = format!( "{} ({} from {source_label})", prepared.summary, prepared.mime ); Ok(CallToolResult::success(vec![ Content::text(header), Content::image(encoded, prepared.mime.to_string()), ])) } fn invalid_params(msg: String) -> ErrorData { ErrorData::invalid_params(msg, None) } /// Fetch the source bytes from path / http(s) / data URL. async fn load_source( state: &SharedState, p: &ViewImageParams, ) -> Result<(Vec, String), ErrorData> { let src = p.source.trim(); if src.starts_with("data:") { let bytes = decode_data_url(src).map_err(invalid_params)?; // `decode_data_url` enforces an encoded-length precheck so we never // allocate past the source cap. Re-verify the decoded length for // belt-and-braces. if bytes.len() > MAX_SOURCE_BYTES { return Err(invalid_params(format!( "data: URL decoded to {} bytes (limit {} bytes)", bytes.len(), MAX_SOURCE_BYTES ))); } Ok((bytes, "data:URL".to_string())) } else if src.starts_with("http://") || src.starts_with("https://") { let bytes = fetch_url(src).await?; Ok((bytes, src.to_string())) } else if src.contains("://") { // Treat any other `scheme://...` form as an explicit reject so // `ftp://...` doesn't accidentally become a filesystem path. Err(invalid_params(format!( "unsupported URL scheme in `source`: {src}", ))) } else { let workspace_root = match p.workdir.as_deref() { Some(w) => PathBuf::from(w), None => state.cwd.clone(), }; let target = resolve_path(&workspace_root, src).map_err(invalid_params)?; let meta = std::fs::metadata(&target).map_err(|e| { ErrorData::internal_error(format!("cannot stat {}: {e}", target.display()), None) })?; if !meta.is_file() { return Err(invalid_params(format!( "not a regular file: {}", target.display() ))); } if meta.len() as usize > MAX_SOURCE_BYTES { return Err(invalid_params(format!( "file too large: {} is {} bytes (limit {} bytes)", target.display(), meta.len(), MAX_SOURCE_BYTES ))); } // Use `take(cap + 1)` so a file that grows between the metadata // check and the read still cannot exceed our budget. The +1 // distinguishes "exactly at cap" from "grew past cap". let file = std::fs::File::open(&target).map_err(|e| { ErrorData::internal_error(format!("cannot open {}: {e}", target.display()), None) })?; let mut bytes = Vec::with_capacity(meta.len() as usize); use std::io::Read; file.take(MAX_SOURCE_BYTES as u64 + 1) .read_to_end(&mut bytes) .map_err(|e| { ErrorData::internal_error(format!("cannot read {}: {e}", target.display()), None) })?; if bytes.len() > MAX_SOURCE_BYTES { return Err(invalid_params(format!( "file {} grew past {} byte cap during read", target.display(), MAX_SOURCE_BYTES ))); } Ok((bytes, target.display().to_string())) } } /// Parse `data:image/[;base64],`. Only base64 payloads are /// accepted — percent-encoded data URLs add surface area for no real benefit. fn decode_data_url(src: &str) -> Result, String> { let rest = src .strip_prefix("data:") .ok_or_else(|| "not a data: URL".to_string())?; let (meta, payload) = rest .split_once(',') .ok_or_else(|| "malformed data: URL (no comma)".to_string())?; // meta is "[;param=value]*[;base64]" let mut parts = meta.split(';'); let mime = parts.next().unwrap_or(""); if !mime.starts_with("image/") { return Err(format!("data: URL is not an image (got `{mime}`)")); } let is_base64 = parts.any(|p| p.eq_ignore_ascii_case("base64")); if !is_base64 { return Err( "data: URL must be base64-encoded (non-base64 / percent-encoded forms are not supported)" .to_string(), ); } let payload = payload.trim(); // Pre-check encoded length so we never allocate past the source cap. // 4 base64 chars encode 3 raw bytes; ceil-divide MAX_SOURCE_BYTES. let max_encoded = MAX_SOURCE_BYTES.div_ceil(3) * 4 + 4; // +4 absorbs padding rounding if payload.len() > max_encoded { return Err(format!( "data: URL payload is {} base64 chars (limit ~{} = {} raw bytes)", payload.len(), max_encoded, MAX_SOURCE_BYTES )); } base64::engine::general_purpose::STANDARD .decode(payload) .map_err(|e| format!("data: URL base64 decode failed: {e}")) } /// Fetch an http(s) URL with a streaming read and a hard byte cap. /// Refuses up-front if `Content-Length` advertises more than the cap. async fn fetch_url(url: &str) -> Result, ErrorData> { let client = reqwest::Client::builder() .connect_timeout(FETCH_TIMEOUT) .timeout(FETCH_TIMEOUT) .build() .map_err(|e| ErrorData::internal_error(format!("http client init failed: {e}"), None))?; let resp = client .get(url) .send() .await .map_err(|e| ErrorData::internal_error(format!("fetch failed: {url} ({e})"), None))?; if !resp.status().is_success() { return Err(invalid_params(format!( "fetch {url} returned HTTP {}", resp.status() ))); } if let Some(len) = resp.content_length() { if len as usize > MAX_SOURCE_BYTES { return Err(invalid_params(format!( "remote image too large: Content-Length {} bytes (limit {})", len, MAX_SOURCE_BYTES ))); } } let mut buf: Vec = Vec::new(); let mut stream = resp; loop { let chunk = stream .chunk() .await .map_err(|e| ErrorData::internal_error(format!("fetch read failed: {e}"), None))?; match chunk { Some(bytes) => { if buf.len() + bytes.len() > MAX_SOURCE_BYTES { return Err(invalid_params(format!( "remote image exceeded {} byte cap mid-stream", MAX_SOURCE_BYTES ))); } buf.extend_from_slice(&bytes); } None => break, } } Ok(buf) } /// Sniff the image format from magic bytes alone (do not trust extensions /// or `Content-Type`). Returns the canonical MIME type. fn sniff_mime(bytes: &[u8]) -> Result<&'static str, String> { // PNG: 89 50 4E 47 0D 0A 1A 0A if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Ok("image/png"); } // JPEG: FF D8 FF if bytes.len() >= 3 && bytes[0..3] == [0xFF, 0xD8, 0xFF] { return Ok("image/jpeg"); } // GIF87a / GIF89a if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { return Ok("image/gif"); } // WebP: "RIFF" .... "WEBP" if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { return Ok("image/webp"); } Err("unsupported image format (recognised: png, jpeg, gif, webp)".to_string()) } /// Detect animated GIF (≥2 image descriptors) or animated WebP (VP8X chunk /// with ANIM bit set). We refuse animated input outright rather than /// silently emit a first-frame still. /// /// **Important**: both branches must be allocation-free byte-level scans. /// Using the `image` crate's `GifDecoder::into_frames()` here would let an /// attacker-controlled logical-screen size trigger a multi-GB RGBA buffer /// before our pixel-count cap fires. fn is_animated(bytes: &[u8], mime: &str) -> bool { match mime { "image/gif" => gif_has_two_image_descriptors(bytes), "image/webp" => { // Animated WebP files always use the extended (VP8X) container. // The animation bit is bit 1 of the flags byte at offset 20. if bytes.len() < 21 { return false; } if &bytes[12..16] != b"VP8X" { return false; } (bytes[20] & 0x02) != 0 } _ => false, } } /// Scan a GIF byte stream and report whether it contains ≥2 image descriptors /// (frames). Does not allocate decode buffers — walks the block structure /// described in the GIF89a spec and bails on the second `0x2C` separator. fn gif_has_two_image_descriptors(bytes: &[u8]) -> bool { // 6-byte header ("GIF87a"/"GIF89a") + 7-byte logical screen descriptor. if bytes.len() < 13 { return false; } let packed = bytes[10]; let has_gct = (packed & 0x80) != 0; let gct_size = if has_gct { 3 * (1u32 << ((packed & 0x07) + 1)) } else { 0 }; let mut i = 13usize + gct_size as usize; let mut frames = 0u32; while let Some(&b) = bytes.get(i) { i += 1; match b { 0x3B => return frames >= 2, // trailer 0x21 => { // Extension introducer: