use std::path::PathBuf;
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;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum SameSiteDto {
Strict,
Lax,
None,
}
impl From<zendriver::SameSite> for SameSiteDto {
fn from(s: zendriver::SameSite) -> Self {
match s {
zendriver::SameSite::Strict => Self::Strict,
zendriver::SameSite::Lax => Self::Lax,
zendriver::SameSite::None => Self::None,
}
}
}
impl From<SameSiteDto> for zendriver::SameSite {
fn from(s: SameSiteDto) -> Self {
match s {
SameSiteDto::Strict => Self::Strict,
SameSiteDto::Lax => Self::Lax,
SameSiteDto::None => Self::None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct CookieDto {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires: Option<f64>,
#[serde(default)]
pub http_only: bool,
#[serde(default)]
pub secure: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub same_site: Option<SameSiteDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
impl From<zendriver::Cookie> for CookieDto {
fn from(c: zendriver::Cookie) -> Self {
Self {
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
expires: c.expires,
http_only: c.http_only,
secure: c.secure,
same_site: c.same_site.map(SameSiteDto::from),
url: c.url,
}
}
}
impl From<CookieDto> for zendriver::Cookie {
fn from(c: CookieDto) -> Self {
Self {
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
expires: c.expires,
http_only: c.http_only,
secure: c.secure,
same_site: c.same_site.map(zendriver::SameSite::from),
url: c.url,
..Default::default()
}
}
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CookiesGetInput {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CookiesGetOutput {
pub cookies: Vec<CookieDto>,
}
pub async fn cookies_get(
state: Arc<Mutex<SessionState>>,
input: CookiesGetInput,
) -> Result<CookiesGetOutput, ErrorData> {
let s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
let jar = b.cookies();
let cs = match input.url.as_deref() {
Some(u) => {
let parsed = url::Url::parse(u)
.map_err(|e| ErrorData::invalid_params(format!("invalid url `{u}`: {e}"), None))?;
jar.for_url(&parsed)
.await
.map_err(|e| map_error(McpServerError::from(e)))?
}
None => jar
.all()
.await
.map_err(|e| map_error(McpServerError::from(e)))?,
};
let filtered: Vec<CookieDto> = match input.name {
Some(n) => cs
.into_iter()
.filter(|c| c.name == n)
.map(CookieDto::from)
.collect(),
None => cs.into_iter().map(CookieDto::from).collect(),
};
Ok(CookiesGetOutput { cookies: filtered })
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CookiesSetInput {
pub cookies: Vec<CookieDto>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CookiesSetOutput {
pub added: usize,
}
pub async fn cookies_set(
state: Arc<Mutex<SessionState>>,
input: CookiesSetInput,
) -> Result<CookiesSetOutput, ErrorData> {
let s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
let count = input.cookies.len();
let cookies: Vec<zendriver::Cookie> = input
.cookies
.into_iter()
.map(zendriver::Cookie::from)
.collect();
b.cookies()
.set_many(cookies)
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
Ok(CookiesSetOutput { added: count })
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CookiesDeleteInput {
pub name: String,
#[serde(default)]
pub domain: Option<String>,
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CookiesDeleteOutput {
pub deleted: bool,
}
pub async fn cookies_delete(
state: Arc<Mutex<SessionState>>,
input: CookiesDeleteInput,
) -> Result<CookiesDeleteOutput, ErrorData> {
let s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
b.cookies()
.delete(&input.name, input.domain.as_deref(), input.path.as_deref())
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
Ok(CookiesDeleteOutput { deleted: true })
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CookiesClearOutput {
pub ok: bool,
}
pub async fn cookies_clear(
state: Arc<Mutex<SessionState>>,
_: crate::tools::common::EmptyInput,
) -> Result<CookiesClearOutput, ErrorData> {
let s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
b.cookies()
.clear()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
Ok(CookiesClearOutput { ok: true })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PersistDirection {
Save,
Load,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CookiesPersistInput {
pub direction: PersistDirection,
pub path: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CookiesPersistOutput {
pub count: usize,
pub direction: PersistDirection,
}
pub async fn cookies_persist(
state: Arc<Mutex<SessionState>>,
input: CookiesPersistInput,
) -> Result<CookiesPersistOutput, ErrorData> {
let s = state.lock().await;
let b = s
.browser
.as_ref()
.ok_or_else(|| map_error(McpServerError::BrowserNotOpen))?;
let jar = b.cookies();
let path = PathBuf::from(&input.path);
let count = match input.direction {
PersistDirection::Save => {
let cookies = jar
.all()
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
let dtos: Vec<CookieDto> = cookies.into_iter().map(CookieDto::from).collect();
let bytes = serde_json::to_vec_pretty(&dtos)
.map_err(|e| ErrorData::internal_error(format!("serialize cookies: {e}"), None))?;
tokio::fs::write(&path, &bytes).await.map_err(|e| {
ErrorData::internal_error(format!("write `{}`: {e}", path.display()), None)
})?;
dtos.len()
}
PersistDirection::Load => {
let bytes = tokio::fs::read(&path).await.map_err(|e| {
ErrorData::internal_error(format!("read `{}`: {e}", path.display()), None)
})?;
let dtos: Vec<CookieDto> = serde_json::from_slice(&bytes).map_err(|e| {
ErrorData::invalid_params(
format!("parse `{}` as cookie JSON: {e}", path.display()),
None,
)
})?;
let count = dtos.len();
let cookies: Vec<zendriver::Cookie> =
dtos.into_iter().map(zendriver::Cookie::from).collect();
jar.set_many(cookies)
.await
.map_err(|e| map_error(McpServerError::from(e)))?;
count
}
};
Ok(CookiesPersistOutput {
count,
direction: input.direction,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::tools::common::EmptyInput;
fn fresh() -> Arc<Mutex<SessionState>> {
Arc::new(Mutex::new(SessionState::new()))
}
#[tokio::test]
async fn cookies_get_with_no_browser_suggests_browser_open() {
let err = cookies_get(fresh(), CookiesGetInput::default())
.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 cookies_set_with_no_browser_suggests_browser_open() {
let err = cookies_set(fresh(), CookiesSetInput { cookies: vec![] })
.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 cookies_delete_with_no_browser_suggests_browser_open() {
let err = cookies_delete(
fresh(),
CookiesDeleteInput {
name: "sid".into(),
domain: None,
path: None,
},
)
.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 cookies_clear_with_no_browser_suggests_browser_open() {
let err = cookies_clear(fresh(), EmptyInput {})
.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 cookies_persist_with_no_browser_suggests_browser_open() {
let err = cookies_persist(
fresh(),
CookiesPersistInput {
direction: PersistDirection::Save,
path: "/tmp/never-touched-no-browser.json".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 cookies_get_invalid_url_returns_invalid_params() {
let bad = url::Url::parse("not a url");
assert!(bad.is_err());
}
#[test]
fn cookie_dto_round_trips_through_lib_cookie() {
let original = CookieDto {
name: "sid".into(),
value: "abc".into(),
domain: ".example.com".into(),
path: "/".into(),
expires: Some(1_700_000_000.5),
http_only: true,
secure: true,
same_site: Some(SameSiteDto::Lax),
url: Some("https://example.com/".into()),
};
let lib: zendriver::Cookie = original.clone().into();
let back: CookieDto = lib.into();
assert_eq!(original, back);
}
#[tokio::test]
async fn cookies_persist_save_load_round_trip_via_serde_shim() {
let tmp = std::env::temp_dir().join(format!(
"zendriver-mcp-cookies-persist-shim-{}.json",
std::process::id()
));
let _ = tokio::fs::remove_file(&tmp).await;
let cookies = vec![
CookieDto {
name: "a".into(),
value: "1".into(),
domain: ".x.com".into(),
path: "/".into(),
expires: None,
http_only: false,
secure: false,
same_site: None,
url: None,
},
CookieDto {
name: "b".into(),
value: "2".into(),
domain: ".y.com".into(),
path: "/".into(),
expires: Some(1_700_000_000.0),
http_only: true,
secure: true,
same_site: Some(SameSiteDto::Strict),
url: None,
},
];
let bytes = serde_json::to_vec_pretty(&cookies).unwrap();
tokio::fs::write(&tmp, &bytes).await.unwrap();
let raw = tokio::fs::read(&tmp).await.unwrap();
let loaded: Vec<CookieDto> = serde_json::from_slice(&raw).unwrap();
assert_eq!(cookies, loaded);
for dto in &loaded {
let lib: zendriver::Cookie = dto.clone().into();
let back: CookieDto = lib.into();
assert_eq!(*dto, back);
}
let _ = tokio::fs::remove_file(&tmp).await;
}
#[test]
fn same_site_dto_maps_all_variants() {
for (dto, lib) in [
(SameSiteDto::Strict, zendriver::SameSite::Strict),
(SameSiteDto::Lax, zendriver::SameSite::Lax),
(SameSiteDto::None, zendriver::SameSite::None),
] {
assert_eq!(SameSiteDto::from(lib), dto);
assert_eq!(zendriver::SameSite::from(dto), lib);
}
}
}