voidcrawl_mcp/tools/
session.rs1use std::{env, sync::Arc, time::Duration};
7
8use rmcp::ErrorData;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use tokio::sync::Mutex;
12use uuid::Uuid;
13use void_crawl_core::{AntibotVerdict, BrowserSession, VoidCrawlError};
14
15use crate::{
16 errors::map_err,
17 server::VoidCrawlServer,
18 sessions::DedicatedSession,
19 tools::{fetch::AntibotInfo, wait},
20};
21
22pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
23
24#[derive(Debug, Deserialize, JsonSchema, Default)]
25pub struct SessionOpenArgs {
26 #[serde(default)]
30 pub headful: bool,
31 #[serde(default)]
33 pub proxy: Option<String>,
34 #[serde(default)]
42 pub user_data_dir: Option<String>,
43 #[serde(default)]
49 pub port: Option<u16>,
50}
51
52#[derive(Debug, Serialize, JsonSchema)]
53pub struct SessionOpenResult {
54 pub session_id: String,
55 pub websocket_url: String,
59 pub port: Option<u16>,
61 pub target_id: String,
63}
64
65#[derive(Debug, Deserialize, JsonSchema, Default)]
66pub struct SessionNavigateArgs {
67 pub session_id: String,
68 pub url: String,
69 #[serde(default)]
71 pub wait_for: Option<String>,
72 #[serde(default)]
73 pub timeout_secs: Option<u64>,
74}
75
76#[derive(Debug, Serialize, JsonSchema)]
77pub struct SessionNavigateResult {
78 pub url: String,
79 pub status_code: Option<u16>,
80 pub redirected: bool,
81 pub antibot: Option<AntibotInfo>,
84}
85
86#[derive(Debug, Deserialize, JsonSchema, Default)]
87pub struct SessionIdArgs {
88 pub session_id: String,
89}
90
91#[derive(Debug, Serialize, JsonSchema)]
92pub struct SessionContentResult {
93 pub url: Option<String>,
94 pub title: Option<String>,
95 pub html: String,
96}
97
98#[derive(Debug, Serialize, JsonSchema)]
99pub struct SessionCloseResult {
100 pub closed: bool,
101}
102
103pub async fn open(
104 server: &VoidCrawlServer,
105 args: SessionOpenArgs,
106) -> Result<SessionOpenResult, ErrorData> {
107 let mut builder = BrowserSession::builder();
108 builder = if args.headful { builder.headful() } else { builder.headless() };
109 if let Some(p) = args.port {
110 builder = builder.port(p);
111 }
112 if let Some(proxy) = args.proxy {
113 builder = builder.proxy(proxy);
114 }
115 if let Some(path) = args.user_data_dir {
116 builder = builder.user_data_dir(expand_tilde(&path));
117 }
118 let session = builder.launch().await.map_err(map_err)?;
119 let page = session.new_blank_page().await.map_err(map_err)?;
120 let websocket_url = session.websocket_url().await;
123 let target_id = page.target_id();
124 let id = Uuid::new_v4().to_string();
125 let handle = Arc::new(DedicatedSession {
126 session: Arc::new(session),
127 page: Mutex::new(page),
128 pending_download: Mutex::new(None),
129 });
130 server.state().sessions.insert(id.clone(), handle).await;
131 Ok(SessionOpenResult { session_id: id, websocket_url, port: args.port, target_id })
132}
133
134pub async fn navigate(
135 server: &VoidCrawlServer,
136 args: SessionNavigateArgs,
137) -> Result<SessionNavigateResult, ErrorData> {
138 let handle = lookup(server, &args.session_id).await?;
139 let page = handle.page.lock().await;
140 let timeout = Duration::from_secs(args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
141 let resp = page.goto_and_wait_for_idle(&args.url, timeout).await.map_err(map_err)?;
142 wait::apply_post_navigate(&page, args.wait_for.as_deref(), timeout).await.map_err(map_err)?;
143 let antibot = resp.antibot.filter(AntibotVerdict::detected).map(AntibotInfo::from);
144 Ok(SessionNavigateResult {
145 url: resp.url,
146 status_code: resp.status_code,
147 redirected: resp.redirected,
148 antibot,
149 })
150}
151
152pub async fn content(
153 server: &VoidCrawlServer,
154 args: SessionIdArgs,
155) -> Result<SessionContentResult, ErrorData> {
156 let handle = lookup(server, &args.session_id).await?;
157 let page = handle.page.lock().await;
158 let html = page.content().await.map_err(map_err)?;
159 let title = page.title().await.ok().flatten();
160 let url = page.url().await.ok().flatten();
161 Ok(SessionContentResult { url, title, html })
162}
163
164pub async fn close(
165 server: &VoidCrawlServer,
166 args: SessionIdArgs,
167) -> Result<SessionCloseResult, ErrorData> {
168 let Some(handle) = server.state().sessions.remove(&args.session_id).await else {
169 return Ok(SessionCloseResult { closed: false });
170 };
171 close_handle(handle).await.map_err(map_err)?;
172 Ok(SessionCloseResult { closed: true })
173}
174
175async fn lookup(server: &VoidCrawlServer, id: &str) -> Result<Arc<DedicatedSession>, ErrorData> {
176 server
177 .state()
178 .sessions
179 .get(id)
180 .await
181 .ok_or_else(|| ErrorData::invalid_params(format!("unknown session_id: {id}"), None))
182}
183
184pub async fn close_handle(handle: Arc<DedicatedSession>) -> Result<(), VoidCrawlError> {
186 handle.session.close().await
187}
188
189fn expand_tilde(path: &str) -> String {
194 let Some(rest) = path.strip_prefix('~') else { return path.to_owned() };
195 let Ok(home) = env::var("HOME") else { return path.to_owned() };
196 if rest.is_empty() {
197 home
198 } else if let Some(tail) = rest.strip_prefix('/') {
199 format!("{home}/{tail}")
200 } else {
201 path.to_owned()
202 }
203}