use std::collections::BTreeMap;
use std::sync::Arc;
use rmcp::ErrorData;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::errors::{McpServerError, map_error};
use crate::selectors::Selector;
use crate::state::SessionState;
use crate::tools::common::current_tab;
use crate::tools::find::{BoundingBox, resolve};
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReadFieldsPreset {
All,
ExistsOnly,
VisibleEnabled,
Geometry,
TextAttrs,
}
const fn default_preset() -> ReadFieldsPreset {
ReadFieldsPreset::All
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ElementStateInput {
#[serde(flatten)]
pub selector: Selector,
#[serde(default = "default_preset")]
pub include: ReadFieldsPreset,
}
#[derive(Debug, Serialize, JsonSchema, Default)]
pub struct ElementState {
pub exists: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub visible: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_viewport: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bounding_box: Option<BoundingBox>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attrs: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outer_html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bounding_box_page: Option<BoundingBox>,
}
pub async fn element_state(
state: Arc<Mutex<SessionState>>,
input: ElementStateInput,
) -> Result<ElementState, ErrorData> {
let s = state.lock().await;
let tab = current_tab(&s).await?;
let el = match resolve(&tab, &input.selector).await {
Ok(el) => el,
Err(err) if is_not_found(&err) => {
return Ok(ElementState {
exists: false,
..Default::default()
});
}
Err(err) => return Err(err),
};
let mut out = ElementState {
exists: true,
..Default::default()
};
let want_visible = matches!(
input.include,
ReadFieldsPreset::All | ReadFieldsPreset::VisibleEnabled
);
let want_geometry = matches!(
input.include,
ReadFieldsPreset::All | ReadFieldsPreset::Geometry
);
let want_text_attrs = matches!(
input.include,
ReadFieldsPreset::All | ReadFieldsPreset::TextAttrs
);
if want_visible {
let visible = el
.is_visible()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
let enabled = el
.is_enabled()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
out.visible = Some(visible);
out.enabled = Some(enabled);
}
if want_geometry {
let bbox = el
.bounding_box()
.await
.ok()
.flatten()
.map(BoundingBox::from);
out.bounding_box = bbox;
out.in_viewport = None;
out.bounding_box_page = el.bounding_box_page().await.ok().flatten().map(|pb| {
let (x, y) = pb.abs_origin();
BoundingBox {
x,
y,
width: pb.viewport.width,
height: pb.viewport.height,
}
});
}
if want_text_attrs {
let text = el
.inner_text()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
let attrs_map = el
.attrs()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
let inner_html = el
.inner_html()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
let outer_html = el
.outer_html()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
out.text = Some(text);
out.attrs = Some(attrs_map.into_iter().collect());
out.inner_html = Some(inner_html);
out.outer_html = Some(outer_html);
}
Ok(out)
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GetLinksInput {
#[serde(default)]
pub absolute: bool,
#[serde(default)]
pub include_sources: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct GetLinksOutput {
pub urls: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sources: Option<Vec<String>>,
}
pub async fn get_links(
state: Arc<Mutex<SessionState>>,
input: GetLinksInput,
) -> Result<GetLinksOutput, ErrorData> {
let s = state.lock().await;
let tab = current_tab(&s).await?;
let urls = tab
.get_all_urls(input.absolute)
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
let sources = if input.include_sources {
let els = tab
.get_all_linked_sources()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
let mut out = Vec::with_capacity(els.len());
for el in &els {
let src = el.attr("src").await.ok().flatten();
let href = match src {
Some(s) => Some(s),
None => el.attr("href").await.ok().flatten(),
};
if let Some(u) = href {
out.push(u);
}
}
Some(out)
} else {
None
};
Ok(GetLinksOutput { urls, sources })
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SearchResourcesInput {
pub query: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ResourceMatch {
pub url: String,
pub frame_id: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct SearchResourcesOutput {
pub matches: Vec<ResourceMatch>,
}
pub async fn search_resources(
state: Arc<Mutex<SessionState>>,
input: SearchResourcesInput,
) -> Result<SearchResourcesOutput, ErrorData> {
let s = state.lock().await;
let tab = current_tab(&s).await?;
let hits = tab
.search_frame_resources(&input.query)
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
let matches = hits
.into_iter()
.map(|m| ResourceMatch {
url: m.url,
frame_id: m.frame_id,
})
.collect();
Ok(SearchResourcesOutput { matches })
}
fn is_not_found(err: &ErrorData) -> bool {
err.data
.as_ref()
.and_then(|v| v.get("suggested_next"))
.and_then(|v| v.as_str())
== Some("browser_html")
&& err.message.contains("No element matched")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::selectors::Selector;
fn fresh() -> Arc<Mutex<SessionState>> {
Arc::new(Mutex::new(SessionState::new()))
}
fn css_sel(s: &str) -> Selector {
Selector {
css: Some(s.into()),
xpath: None,
text: None,
text_exact: None,
text_regex: None,
role: None,
role_name: None,
tag: None,
attrs: vec![],
nth: None,
visible_only: true,
timeout_ms: 5000,
frame_id: None,
}
}
#[tokio::test]
async fn element_state_with_no_browser_suggests_browser_open() {
let err = element_state(
fresh(),
ElementStateInput {
selector: css_sel("h1"),
include: ReadFieldsPreset::All,
},
)
.await
.expect_err("must error without an open browser");
assert!(err.message.contains("browser_open"), "msg: {}", err.message);
let data = err.data.as_ref().expect("data populated");
assert_eq!(data["suggested_next"], "browser_open");
}
#[test]
fn default_preset_is_all() {
assert_eq!(default_preset(), ReadFieldsPreset::All);
}
}