use std::io::Cursor;
use std::time::Duration;
use base64::Engine;
use image::ImageFormat;
use rmcp::{
ErrorData as McpError, handler::server::wrapper::Parameters, model::*, schemars, tool,
tool_router,
};
use serde::Deserialize;
use crate::audit::AuditRecord;
use crate::errors::SshError;
use crate::guards;
use crate::output::Toon;
use crate::server::SshServer;
use crate::session::exec;
use crate::sftp;
use crate::tools::shell_quote;
const DEFAULT_MAX_WIDTH: u32 = 1024;
const MAX_MAX_WIDTH: u32 = 3840;
const DEFAULT_QUALITY: u8 = 75;
const MAX_CAPTURE_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ShotFormat {
Jpeg,
Png,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ShotArgs {
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub display: Option<String>,
#[serde(default)]
pub local: Option<String>,
#[serde(default)]
pub max_width: Option<u32>,
#[serde(default)]
pub format: Option<ShotFormat>,
#[serde(default)]
pub quality: Option<u8>,
}
const BACKENDS: &[(&str, &str, bool)] = &[
("grim", "grim -t png {}", false),
("gnome-screenshot", "gnome-screenshot -f {}", true),
("spectacle", "spectacle -b -n -o {}", true),
("import", "import -window root {}", true),
("scrot", "scrot -o {}", true),
];
#[tool_router(router = visual_router, vis = "pub")]
impl SshServer {
#[tool(
description = "Screenshot the remote desktop and return it as an image. Downscaled before it reaches you; local=<path> writes the original to disk instead. Needs a graphical session.",
annotations(
title = "Shot",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = true
)
)]
async fn shot(
&self,
Parameters(args): Parameters<ShotArgs>,
) -> Result<CallToolResult, McpError> {
let host_name = self.resolve_host(args.host)?;
let local_path = args.local.as_deref().map(guards::resolve_local_path);
if let Some(p) = local_path.as_deref()
&& let Err(e) = guards::check_local_write(p)
{
let reason = e.to_string();
self.audit.write(
&host_name,
"shot",
AuditRecord {
cmd: args.local.as_deref(),
blocked: Some(&reason),
error: Some(reason.clone()),
..Default::default()
},
);
return Err(e.into_mcp());
}
let facts = self.host_facts(&host_name, false).await?;
let (bin, template, needs_display) = BACKENDS
.iter()
.find(|(bin, _, _)| facts.has(bin))
.ok_or_else(|| {
SshError::Other(format!(
"no screenshot backend on {host_name}: none of {} are installed",
BACKENDS
.iter()
.map(|(b, _, _)| *b)
.collect::<Vec<_>>()
.join(", ")
))
.into_mcp()
})?;
let session = self
.pool
.get_or_connect(&host_name, None)
.await
.map_err(|e| e.into_mcp())?;
let remote = format!("/tmp/.fast-mcp-ssh-shot-{}.png", nonce());
let quoted = shell_quote(&remote);
let display = args.display.as_deref().unwrap_or(":0");
let env = if *needs_display {
format!("DISPLAY={} ", shell_quote(display))
} else {
"XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)} ".to_string()
};
let cmd = format!("{env}{}", template.replace("{}", "ed));
let r = exec::exec(
&session,
&cmd,
Duration::from_secs(30),
self.cfg().defaults.max_capture_bytes,
)
.await
.map_err(|e| e.into_mcp())?;
if r.exit_code != 0 {
let _ = sftp::remove(&session, &remote, false).await;
return Err(SshError::Other(format!(
"{bin} failed on {host_name} (exit {}): {}",
r.exit_code,
r.stderr.trim()
))
.into_mcp());
}
let fetched =
sftp::download(&session, &remote, local_path.as_deref(), MAX_CAPTURE_BYTES).await;
let _ = sftp::remove(&session, &remote, false).await;
let (transfer, raw) = fetched.map_err(|e| e.into_mcp())?;
let mut t = Toon::new();
t.field("host", &host_name)
.field("backend", *bin)
.field("bytes", transfer.bytes)
.field("ms", transfer.duration_ms as u64);
let Some(raw) = raw else {
if let Some(p) = args.local.as_deref() {
t.field("local", p);
} else {
t.field(
"error",
"capture exceeded the 64 MB cap; rerun with local=<path>",
);
}
self.audit.write(
&host_name,
"shot",
AuditRecord {
cmd: Some(&remote),
exit_code: Some(r.exit_code),
duration_ms: Some(transfer.duration_ms),
bytes_out: Some(transfer.bytes),
..Default::default()
},
);
return Ok(crate::tools::text(t.into_string()));
};
let max_width = args
.max_width
.unwrap_or(DEFAULT_MAX_WIDTH)
.clamp(16, MAX_MAX_WIDTH);
let format = args.format.unwrap_or(ShotFormat::Jpeg);
let quality = args.quality.unwrap_or(DEFAULT_QUALITY).clamp(1, 100);
let encoded =
tokio::task::spawn_blocking(move || downscale(&raw, max_width, format, quality))
.await
.map_err(|e| SshError::Other(format!("image worker: {e}")).into_mcp())?
.map_err(|e| e.into_mcp())?;
t.field("width", encoded.width as u64)
.field("height", encoded.height as u64)
.field(
"format",
if format == ShotFormat::Png {
"png"
} else {
"jpeg"
},
)
.field("image_bytes", encoded.data.len());
self.audit.write(
&host_name,
"shot",
AuditRecord {
cmd: Some(&remote),
exit_code: Some(r.exit_code),
duration_ms: Some(transfer.duration_ms),
bytes_out: Some(transfer.bytes),
..Default::default()
},
);
let mime = if format == ShotFormat::Png {
"image/png"
} else {
"image/jpeg"
};
Ok(CallToolResult::success(vec![
ContentBlock::text(t.into_string()),
ContentBlock::image(
base64::engine::general_purpose::STANDARD.encode(&encoded.data),
mime,
),
]))
}
}
struct Encoded {
data: Vec<u8>,
width: u32,
height: u32,
}
fn downscale(
raw: &[u8],
max_width: u32,
format: ShotFormat,
quality: u8,
) -> crate::errors::Result<Encoded> {
let img = image::load_from_memory(raw)
.map_err(|e| SshError::Other(format!("cannot decode the capture: {e}")))?;
let img = if img.width() > max_width {
let height = ((img.height() as u64 * max_width as u64) / img.width() as u64).max(1) as u32;
img.resize_exact(max_width, height, image::imageops::FilterType::Triangle)
} else {
img
};
let (width, height) = (img.width(), img.height());
let mut data = Vec::new();
match format {
ShotFormat::Png => img
.write_to(&mut Cursor::new(&mut data), ImageFormat::Png)
.map_err(|e| SshError::Other(format!("cannot encode png: {e}")))?,
ShotFormat::Jpeg => {
let rgb = img.to_rgb8();
let mut enc =
image::codecs::jpeg::JpegEncoder::new_with_quality(Cursor::new(&mut data), quality);
enc.encode_image(&rgb)
.map_err(|e| SshError::Other(format!("cannot encode jpeg: {e}")))?;
}
}
Ok(Encoded {
data,
width,
height,
})
}
fn nonce() -> String {
let mut b = [0u8; 8];
if getrandom::fill(&mut b).is_err() {
b = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0))
.to_le_bytes();
}
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_fn(w, h, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
});
let mut out = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
#[test]
fn downscale_fits_the_width_and_keeps_the_aspect() {
let e = downscale(&png(1920, 1080), 1024, ShotFormat::Jpeg, 75).unwrap();
assert_eq!(e.width, 1024);
assert_eq!(e.height, 576);
assert!(!e.data.is_empty());
}
#[test]
fn downscale_never_upscales() {
let e = downscale(&png(320, 200), 1024, ShotFormat::Png, 75).unwrap();
assert_eq!((e.width, e.height), (320, 200));
}
#[test]
fn lower_jpeg_quality_costs_fewer_bytes() {
let raw = png(800, 600);
let low = downscale(&raw, 800, ShotFormat::Jpeg, 10).unwrap();
let high = downscale(&raw, 800, ShotFormat::Jpeg, 95).unwrap();
assert!(low.data.len() < high.data.len());
}
#[test]
fn a_capture_that_is_not_an_image_is_an_error() {
assert!(downscale(b"not an image at all", 1024, ShotFormat::Jpeg, 75).is_err());
}
#[test]
fn nonces_differ() {
assert_ne!(nonce(), nonce());
}
#[test]
fn every_backend_template_has_an_output_slot() {
for (bin, template, _) in BACKENDS {
assert!(template.contains("{}"), "{bin} has no output placeholder");
assert!(
template.starts_with(bin),
"{bin} template runs something else"
);
}
}
#[test]
fn every_backend_is_one_the_facts_probe_looks_for() {
let probe = crate::tools::ops::FACTS_PROBE;
let listed: Vec<&str> = probe
.lines()
.find(|l| l.trim_start().starts_with("for c in "))
.expect("probe has a `for c in` loop")
.trim()
.trim_start_matches("for c in ")
.trim_end_matches("; do")
.split_whitespace()
.collect();
for (bin, _, _) in BACKENDS {
assert!(
listed.contains(bin),
"{bin} is a backend the facts probe never reports (probed: {listed:?})"
);
}
}
}