use std::{sync::Arc, time::Duration};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use rmcp::{
ErrorData,
model::{CallToolResult, Content},
};
use schemars::JsonSchema;
use serde::Deserialize;
use void_crawl_core::{Page, ScreenshotOptions, ScreenshotOutput, VoidCrawlError};
use crate::{
errors::map_err,
server::VoidCrawlServer,
sessions::DedicatedSession,
tools::{
selector::SelectorArg,
viewport::{BboxArg, ScrollArg, ViewportArg},
wait,
},
};
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct ScreenshotArgs {
pub url: String,
#[serde(default)]
pub wait_for: Option<String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub viewport: Option<ViewportArg>,
#[serde(default)]
pub bbox: Option<BboxArg>,
#[serde(default)]
pub selector: Option<SelectorArg>,
#[serde(default)]
pub scroll: Option<ScrollArg>,
#[serde(default)]
pub full_page: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct SessionScreenshotArgs {
pub session_id: String,
#[serde(default)]
pub viewport: Option<ViewportArg>,
#[serde(default)]
pub bbox: Option<BboxArg>,
#[serde(default)]
pub selector: Option<SelectorArg>,
#[serde(default)]
pub scroll: Option<ScrollArg>,
#[serde(default)]
pub full_page: Option<bool>,
}
#[allow(clippy::too_many_arguments)]
fn build_options(
viewport: Option<&ViewportArg>,
bbox: Option<&BboxArg>,
selector: Option<SelectorArg>,
scroll: Option<&ScrollArg>,
full_page: Option<bool>,
) -> Result<ScreenshotOptions, ErrorData> {
if bbox.is_some() && selector.is_some() {
return Err(ErrorData::invalid_params(
"`bbox` and `selector` are mutually exclusive",
None,
));
}
let mut opts = ScreenshotOptions::default();
if let Some(v) = viewport {
opts = opts.with_viewport(v.resolve()?);
}
if let Some(b) = bbox {
opts = opts.with_bbox((*b).into());
}
if let Some(s) = selector {
opts = opts.with_selector(s.into());
}
if let Some(s) = scroll {
opts = opts.with_scroll(s.resolve()?);
}
if full_page == Some(false) {
opts = opts.viewport_only();
}
Ok(opts)
}
pub async fn run(
server: &VoidCrawlServer,
args: ScreenshotArgs,
) -> Result<CallToolResult, ErrorData> {
let opts = build_options(
args.viewport.as_ref(),
args.bbox.as_ref(),
args.selector,
args.scroll.as_ref(),
args.full_page,
)?;
let pool = server.state().pool().await.map_err(map_err)?;
let tab = pool.acquire().await.map_err(map_err)?;
let result = async {
let timeout = Duration::from_secs(args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
tab.page.goto_and_wait_for_idle(&args.url, timeout).await?;
wait::apply_post_navigate(&tab.page, args.wait_for.as_deref(), timeout).await?;
capture(&tab.page, opts).await
}
.await;
pool.release(tab).await;
let (bytes, dpr) = result.map_err(map_err)?;
Ok(to_call_result(&bytes, dpr, &args.url))
}
pub async fn session(
server: &VoidCrawlServer,
args: SessionScreenshotArgs,
) -> Result<CallToolResult, ErrorData> {
let opts = build_options(
args.viewport.as_ref(),
args.bbox.as_ref(),
args.selector,
args.scroll.as_ref(),
args.full_page,
)?;
let handle = lookup(server, &args.session_id).await?;
let page = handle.page.lock().await;
let (bytes, dpr) = capture(&page, opts).await.map_err(map_err)?;
let url = page.url().await.ok().flatten().unwrap_or(args.session_id);
Ok(to_call_result(&bytes, dpr, &url))
}
async fn lookup(server: &VoidCrawlServer, id: &str) -> Result<Arc<DedicatedSession>, ErrorData> {
server
.state()
.sessions
.get(id)
.await
.ok_or_else(|| ErrorData::invalid_params(format!("unknown session_id: {id}"), None))
}
async fn capture(page: &Page, opts: ScreenshotOptions) -> Result<(Vec<u8>, f64), VoidCrawlError> {
let output = page.screenshot(opts).await?;
let bytes = match output {
ScreenshotOutput::Bytes(b) => b,
ScreenshotOutput::Path(_) => unreachable!("MCP screenshot tools never set a path"),
};
let dpr = page
.evaluate_js("window.devicePixelRatio")
.await
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(1.0);
Ok((bytes, dpr))
}
fn to_call_result(bytes: &[u8], dpr: f64, label: &str) -> CallToolResult {
let len = bytes.len();
let encoded = B64.encode(bytes);
CallToolResult::success(vec![
Content::text(format!(
"{len} bytes PNG of {label} (devicePixelRatio={dpr}; divide screenshot pixels by DPR before click_visual_coords)"
)),
Content::image(encoded, "image/png"),
])
}