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::state::SessionState;
use crate::tools::common::EmptyInput;
#[derive(Debug, Serialize, JsonSchema, PartialEq, Eq)]
pub struct TabSummary {
pub id: String,
pub url: String,
pub title: String,
pub is_current: bool,
}
async fn summarize_tab(tab: &zendriver::Tab, current_tab_id: Option<&str>) -> TabSummary {
let id = tab.target_id().to_string();
let is_current = current_tab_id == Some(id.as_str());
let url = tab.url().await.map(|u| u.to_string()).unwrap_or_default();
let title = tab.title().await.unwrap_or_default();
TabSummary {
id,
url,
title,
is_current,
}
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct TabListOutput {
pub tabs: Vec<TabSummary>,
}
pub async fn list(
state: Arc<Mutex<SessionState>>,
_: EmptyInput,
) -> Result<TabListOutput, ErrorData> {
let s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
let tabs = b.tabs().await;
let mut out = Vec::with_capacity(tabs.len());
let current = s.current_tab_id.as_deref();
for t in &tabs {
out.push(summarize_tab(t, current).await);
}
Ok(TabListOutput { tabs: out })
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TabNewInput {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_true")]
pub activate: bool,
}
const fn default_true() -> bool {
true
}
pub async fn new_tab(
state: Arc<Mutex<SessionState>>,
input: TabNewInput,
) -> Result<TabSummary, ErrorData> {
let mut s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
let tab = match input.url.as_deref() {
Some(u) => b
.new_tab_at(u)
.await
.map_err(|e| map_error(McpServerError::from(e)))?,
None => b
.new_tab()
.await
.map_err(|e| map_error(McpServerError::from(e)))?,
};
let id = tab.target_id().to_string();
if input.activate {
s.current_tab_id = Some(id.clone());
}
let current = s.current_tab_id.as_deref();
Ok(summarize_tab(&tab, current).await)
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TabSwitchInput {
pub tab_id: String,
}
pub async fn switch(
state: Arc<Mutex<SessionState>>,
input: TabSwitchInput,
) -> Result<TabSummary, ErrorData> {
let mut s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
let tabs = b.tabs().await;
let tab = tabs
.iter()
.find(|t| t.target_id() == input.tab_id)
.cloned()
.ok_or_else(|| {
map_error(McpServerError::from(
zendriver::ZendriverError::TabNotFound(input.tab_id.clone()),
))
})?;
s.current_tab_id = Some(input.tab_id.clone());
let current = s.current_tab_id.as_deref();
Ok(summarize_tab(&tab, current).await)
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TabCloseInput {
#[serde(default)]
pub tab_id: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct TabCloseOutput {
pub closed_id: String,
pub current_tab_id: Option<String>,
}
pub async fn close(
state: Arc<Mutex<SessionState>>,
input: TabCloseInput,
) -> Result<TabCloseOutput, ErrorData> {
let mut s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
let target_id = match input.tab_id {
Some(id) => id,
None => s
.current_tab_id
.clone()
.ok_or_else(|| map_error(McpServerError::NoCurrentTab))?,
};
let tabs = b.tabs().await;
let tab = tabs
.iter()
.find(|t| t.target_id() == target_id)
.cloned()
.ok_or_else(|| {
map_error(McpServerError::from(
zendriver::ZendriverError::TabNotFound(target_id.clone()),
))
})?;
tab.close()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
let was_current = s.current_tab_id.as_deref() == Some(target_id.as_str());
if was_current {
let b = s.browser.as_ref().expect("browser still attached");
let remaining = b.tabs().await;
s.current_tab_id = remaining
.iter()
.find(|t| t.target_id() != target_id)
.map(|t| t.target_id().to_string());
}
Ok(TabCloseOutput {
closed_id: target_id,
current_tab_id: s.current_tab_id.clone(),
})
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TabActivateInput {
pub tab_id: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct TabActivateOutput {
pub id: String,
}
pub async fn activate(
state: Arc<Mutex<SessionState>>,
input: TabActivateInput,
) -> Result<TabActivateOutput, ErrorData> {
let mut s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
let tabs = b.tabs().await;
let tab = tabs
.iter()
.find(|t| t.target_id() == input.tab_id)
.cloned()
.ok_or_else(|| {
map_error(McpServerError::from(
zendriver::ZendriverError::TabNotFound(input.tab_id.clone()),
))
})?;
tab.activate()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
tab.bring_to_front()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
s.current_tab_id = Some(input.tab_id.clone());
Ok(TabActivateOutput { id: input.tab_id })
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh() -> Arc<Mutex<SessionState>> {
Arc::new(Mutex::new(SessionState::new()))
}
#[tokio::test]
async fn list_with_no_browser_suggests_browser_open() {
let err = list(fresh(), EmptyInput {})
.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");
}
#[tokio::test]
async fn new_with_no_browser_suggests_browser_open() {
let err = new_tab(
fresh(),
TabNewInput {
url: None,
activate: true,
},
)
.await
.expect_err("must error without an open browser");
assert!(err.message.contains("browser_open"));
let data = err.data.as_ref().expect("data populated");
assert_eq!(data["suggested_next"], "browser_open");
}
#[tokio::test]
async fn switch_with_no_browser_suggests_browser_open() {
let err = switch(
fresh(),
TabSwitchInput {
tab_id: "T0".into(),
},
)
.await
.expect_err("must error without an open browser");
assert!(err.message.contains("browser_open"));
let data = err.data.as_ref().expect("data populated");
assert_eq!(data["suggested_next"], "browser_open");
}
#[tokio::test]
async fn close_with_no_browser_suggests_browser_open() {
let err = close(fresh(), TabCloseInput::default())
.await
.expect_err("must error without an open browser");
assert!(err.message.contains("browser_open"));
let data = err.data.as_ref().expect("data populated");
assert_eq!(data["suggested_next"], "browser_open");
}
#[tokio::test]
async fn activate_with_no_browser_suggests_browser_open() {
let err = activate(
fresh(),
TabActivateInput {
tab_id: "T0".into(),
},
)
.await
.expect_err("must error without an open browser");
assert!(err.message.contains("browser_open"));
let data = err.data.as_ref().expect("data populated");
assert_eq!(data["suggested_next"], "browser_open");
}
}