//! `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); /// Lifetime of a Blossom `t=get` read token for relay media fetches. /// Matches the desktop client's `MEDIA_GET_AUTH_EXPIRY_SECS`. const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; /// 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}")) } /// True when `target` is a relay-hosted Blossom media URL: an http(s) URL /// whose path is under `/media/` and whose authority (host + effective port) /// matches the configured relay URL. The relay URL may use ws/wss schemes — /// the url crate's known default ports (ws=80/wss=443) make those compare /// equal to their http/https counterparts. /// /// Safety contract: this is the *only* gate that decides whether we attach a /// signed auth header. It must never match arbitrary third-party origins, /// where the bearer token would leak. fn is_relay_media_url(target: &reqwest::Url, relay: &reqwest::Url) -> bool { if !matches!(target.scheme(), "http" | "https") { return false; } if !target.path().starts_with("/media/") { return false; } target.host_str().is_some() && target.host_str() == relay.host_str() && target.port_or_known_default() == relay.port_or_known_default() } /// Sign a Blossom (BUD-01) `t=get` authorization event, server-scoped to /// `authority` (`host[:port]`), and return the full `Authorization` header /// value. Mirrors the desktop client's signer: kind 24242, `t=get`, /// `expiration`, `server` tag — no `x` tag (BUD-01 allows x OR server). fn sign_media_get_auth(keys: &nostr::Keys, authority: &str) -> Result { use nostr::{EventBuilder, JsonUtil, Kind, Tag, Timestamp}; let now = Timestamp::now().as_secs(); let tags = vec![ Tag::parse(["t", "get"]).map_err(|e| e.to_string())?, Tag::parse([ "expiration", &(now + MEDIA_GET_AUTH_EXPIRY_SECS).to_string(), ]) .map_err(|e| e.to_string())?, Tag::parse(["server", authority]).map_err(|e| e.to_string())?, ]; let event = EventBuilder::new(Kind::from(24242), "Get buzz-media") .tags(tags) .sign_with_keys(keys) .map_err(|e| e.to_string())?; Ok(format!( "Nostr {}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) )) } /// `host[:port]` for the `server` tag, normalized the same way relay tenant /// lookup normalizes Host/RELAY_URL. Explicit non-default ports and IPv6 /// brackets are preserved; default ports collapse to the bare host. fn server_authority(url: &reqwest::Url) -> Option { let authority = buzz_core::tenant::relay_url_authority(url.as_str()); if authority.is_empty() { None } else { Some(authority) } } /// Mint a `t=get` Authorization header for `url` when it is relay-hosted /// media and `BUZZ_PRIVATE_KEY` is available; `None` otherwise. /// /// Fail-open by design: while the relay's media-read-auth flag is off, an /// unauthenticated request still succeeds, so a missing/invalid key degrades /// to an unsigned fetch instead of an error. Once the flag is on, the fetch /// 403s and the error path below names the missing key. fn relay_media_get_auth(url: &reqwest::Url) -> Option { let relay = std::env::var("BUZZ_RELAY_URL").ok()?; let relay = reqwest::Url::parse(&relay).ok()?; if !is_relay_media_url(url, &relay) { return None; } let key = std::env::var("BUZZ_PRIVATE_KEY").ok()?; let keys = match nostr::Keys::parse(&key) { Ok(k) => k, Err(e) => { tracing::warn!("BUZZ_PRIVATE_KEY invalid; fetching relay media unauthenticated: {e}"); return None; } }; let authority = server_authority(url)?; match sign_media_get_auth(&keys, &authority) { Ok(header) => Some(header), Err(e) => { tracing::warn!("media get auth signing failed; fetching unauthenticated: {e}"); None } } } /// 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. /// Relay-hosted `/media/` URLs get a signed Blossom `t=get` header when /// `BUZZ_RELAY_URL` + `BUZZ_PRIVATE_KEY` are configured. async fn fetch_url(url: &str) -> Result, ErrorData> { let parsed = reqwest::Url::parse(url) .map_err(|e| invalid_params(format!("invalid URL: {url} ({e})")))?; let auth = relay_media_get_auth(&parsed); let mut client_builder = reqwest::Client::builder() .connect_timeout(FETCH_TIMEOUT) .timeout(FETCH_TIMEOUT); if auth.is_some() { // Do not forward Buzz auth headers to a redirect target. reqwest strips // Authorization cross-host, but `x-auth-tag` is not on reqwest's // sensitive-header list. client_builder = client_builder.redirect(reqwest::redirect::Policy::none()); } let client = client_builder .build() .map_err(|e| ErrorData::internal_error(format!("http client init failed: {e}"), None))?; let mut req = client.get(parsed); let authed = auth.is_some(); if let Some(header) = auth { req = req.header("Authorization", header); if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { if !auth_tag.trim().is_empty() { req = req.header("x-auth-tag", auth_tag); } } } let resp = req .send() .await .map_err(|e| ErrorData::internal_error(format!("fetch failed: {url} ({e})"), None))?; if !resp.status().is_success() { let status = resp.status(); if matches!(status.as_u16(), 401 | 403) && !authed { return Err(invalid_params(format!( "fetch {url} returned HTTP {status} — this relay requires authenticated media \ reads; set BUZZ_PRIVATE_KEY (and BUZZ_RELAY_URL) to a member identity" ))); } return Err(invalid_params(format!( "fetch {url} returned HTTP {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: