use chromiumoxide_cdp::cdp::js_protocol::runtime::{CallArgument, CallFunctionOnParams};
use kodegen_mcp_schema::browser::{
BrowserScrollArgs, BrowserScrollOutput, BROWSER_SCROLL,
ScrollPrompts,
};
use kodegen_mcp_schema::{Tool, ToolExecutionContext, ToolResponse, McpError};
use serde_json::json;
use std::sync::Arc;
use tracing::warn;
use crate::manager::BrowserManager;
#[derive(Clone)]
pub struct BrowserScrollTool {
manager: Arc<BrowserManager>,
}
impl BrowserScrollTool {
pub fn new(manager: Arc<BrowserManager>) -> Self {
Self { manager }
}
}
impl Tool for BrowserScrollTool {
type Args = BrowserScrollArgs;
type Prompts = ScrollPrompts;
fn name() -> &'static str {
BROWSER_SCROLL
}
fn description() -> &'static str {
"Scroll the page by amount or to a specific element.\\n\\n\
Examples:\\n\
- browser_scroll({\"y\": 500}) - Scroll down 500px\\n\
- browser_scroll({\"selector\": \"#footer\"}) - Scroll to element"
}
fn read_only() -> bool {
false }
async fn execute(&self, args: Self::Args, _ctx: ToolExecutionContext) -> Result<ToolResponse<BrowserScrollOutput>, McpError> {
let browser_arc = self
.manager
.get_or_launch()
.await
.map_err(|e| McpError::Other(anyhow::anyhow!("Browser error: {}", e)))?;
let browser_guard = browser_arc.lock().await;
let wrapper = browser_guard.as_ref().ok_or_else(|| {
McpError::Other(anyhow::anyhow!(
"Browser not available. This is an internal error - please report it."
))
})?;
let page = crate::browser::get_current_page(wrapper)
.await
.map_err(|e| {
McpError::Other(anyhow::anyhow!(
"Failed to get page. Did you call browser_navigate first? Error: {}",
e
))
})?;
if let Some(selector) = &args.selector {
let element = page.find_element(selector).await.map_err(|e| {
McpError::Other(anyhow::anyhow!(
"Element not found for selector '{}'. \
Verify: (1) Selector syntax is valid CSS, \
(2) Element exists on current page, \
(3) Element is not in an iframe (unsupported). \
Error: {}",
selector,
e
))
})?;
element.scroll_into_view().await.map_err(|e| {
McpError::Other(anyhow::anyhow!(
"Scroll to element failed. \
Possible causes: (1) Element is not scrollable or not in viewport, \
(2) Page structure prevents scrolling, \
(3) Element is detached from DOM. \
Error: {}",
e
))
})?;
let summary = format!(
"\x1b[33m ↻ Scroll: to element\x1b[0m\n\
Selector: {} · Action: scroll_to_element",
selector
);
let output = BrowserScrollOutput {
success: true,
direction: "to_element".to_string(),
amount: 0,
message: format!("Scrolled to element: {}", selector),
};
Ok(ToolResponse::new(summary, output))
} else {
let x = args.x.unwrap_or(0).clamp(-10_000, 10_000);
let y = args.y.unwrap_or(0).clamp(-10_000, 10_000);
if x == 0 && y == 0 {
warn!("Scroll called with x=0, y=0 (no-op)");
}
let call = CallFunctionOnParams::builder()
.function_declaration("(x, y) => window.scrollBy(x, y)")
.argument(CallArgument::builder().value(json!(x)).build())
.argument(CallArgument::builder().value(json!(y)).build())
.build()
.map_err(|e| {
McpError::Other(anyhow::anyhow!("Failed to build scroll params: {}", e))
})?;
page.evaluate_function(call).await.map_err(|e| {
McpError::Other(anyhow::anyhow!(
"Scroll by amount failed. \
Possible causes: (1) Page does not support scrolling, \
(2) Scroll amount exceeds page boundaries, \
(3) JavaScript execution was blocked. \
Error: {}",
e
))
})?;
let direction = match (x, y) {
(0, 0) => "none",
(0, y_val) if y_val > 0 => "down",
(0, _) => "up",
(x_val, 0) if x_val > 0 => "right",
(_, 0) => "left",
(x_val, y_val) if x_val > 0 && y_val > 0 => "down-right",
(x_val, y_val) if x_val < 0 && y_val > 0 => "down-left",
(x_val, y_val) if x_val > 0 && y_val < 0 => "up-right",
_ => "up-left",
};
let total_distance = x.abs() + y.abs();
let summary = format!(
"\x1b[33m ↻ Scroll: {}\x1b[0m\n\
Direction: {} · Distance: {}px",
direction, direction, total_distance
);
let output = BrowserScrollOutput {
success: true,
direction: direction.to_string(),
amount: total_distance,
message: format!("Scrolled by x={}, y={}", x, y),
};
Ok(ToolResponse::new(summary, output))
}
}
}