Skip to main content

voidcrawl_mcp/tools/
session.rs

1//! Stateful session tools. Each `session_open` launches a dedicated
2//! headless `BrowserSession` with its own temporary profile; callers
3//! hold the returned `session_id` across tool calls until
4//! `session_close`.
5
6use 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::{
14    AntibotVerdict, BrowserSession, ManagedProfileLease, ProfileRegistry, VoidCrawlError,
15};
16
17use crate::{
18    errors::map_err,
19    server::VoidCrawlServer,
20    sessions::{DedicatedSession, LastNavigation},
21    tools::{fetch::AntibotInfo, wait},
22};
23
24pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
25
26#[derive(Debug, Deserialize, JsonSchema, Default)]
27pub struct SessionOpenArgs {
28    /// Run headful (visible) instead of headless. Default is headless.
29    /// Set this to true if you want to log into a site manually in the
30    /// spawned Chrome window (pair with `user_data_dir` to persist).
31    #[serde(default)]
32    pub headful:       bool,
33    /// Optional proxy URL (e.g. "http://user:pass@host:port").
34    #[serde(default)]
35    pub proxy:         Option<String>,
36    /// Persistent Chrome profile directory. Omit for an ephemeral,
37    /// cookieless profile. Provide a path (e.g.
38    /// `~/.config/voidcrawl-linkedin`) to mount a profile across
39    /// sessions — log in once with `headful=true`, then subsequent
40    /// sessions reuse the cookie. Pick a path DEDICATED to voidcrawl;
41    /// Chrome locks a profile while running, so pointing at your
42    /// daily-driver profile while normal Chrome is open will fail.
43    #[serde(default)]
44    pub user_data_dir: Option<String>,
45    /// VoidCrawl-managed profile id. Mutually exclusive with `profile_pool`
46    /// and `user_data_dir`.
47    #[serde(default)]
48    pub profile_id:    Option<String>,
49    /// VoidCrawl-managed profile pool. Leases one available profile from the
50    /// pool's active window and returns the selected `profile_id`.
51    #[serde(default)]
52    pub profile_pool:  Option<String>,
53    /// Pin Chrome's `--remote-debugging-port` so another process can attach to
54    /// this session's browser via the returned `websocket_url` (e.g. the
55    /// OpenSesame solver MCP, to solve a captcha on this exact tab). Omit to
56    /// let the OS pick a free ephemeral port — the `websocket_url` is
57    /// returned either way.
58    #[serde(default)]
59    pub port:          Option<u16>,
60}
61
62#[derive(Debug, Serialize, JsonSchema)]
63pub struct SessionOpenResult {
64    pub session_id:    String,
65    /// CDP WebSocket endpoint of this session's browser. Hand this together
66    /// with `target_id` to an external solver so it can attach to the *same*
67    /// Chrome and adopt this tab without opening a new one.
68    pub websocket_url: String,
69    /// The pinned remote-debugging port, if one was requested via `port`.
70    pub port:          Option<u16>,
71    /// CDP target id of this session's page — the exact tab to adopt.
72    pub target_id:     String,
73    /// Selected VoidCrawl-managed profile id when `profile_id` or
74    /// `profile_pool` was used.
75    pub profile_id:    Option<String>,
76}
77
78#[derive(Debug, Deserialize, JsonSchema, Default)]
79pub struct SessionNavigateArgs {
80    pub session_id:   String,
81    pub url:          String,
82    /// "networkidle" (default) or "selector:<css>". Event-driven.
83    #[serde(default)]
84    pub wait_for:     Option<String>,
85    #[serde(default)]
86    pub timeout_secs: Option<u64>,
87}
88
89#[derive(Debug, Serialize, JsonSchema)]
90pub struct SessionNavigateResult {
91    pub url:         String,
92    pub status_code: Option<u16>,
93    pub redirected:  bool,
94    /// Anti-bot / CDN vendor fingerprint of the navigated response, or `null`
95    /// when no vendor was detected. See [`crate::tools::fetch::AntibotInfo`].
96    pub antibot:     Option<AntibotInfo>,
97}
98
99#[derive(Debug, Deserialize, JsonSchema, Default)]
100pub struct SessionIdArgs {
101    pub session_id: String,
102}
103
104#[derive(Debug, Serialize, JsonSchema)]
105pub struct SessionContentResult {
106    pub url:   Option<String>,
107    pub title: Option<String>,
108    pub html:  String,
109}
110
111#[derive(Debug, Serialize, JsonSchema)]
112pub struct SessionCloseResult {
113    pub closed: bool,
114}
115
116pub async fn open(
117    server: &VoidCrawlServer,
118    args: SessionOpenArgs,
119) -> Result<SessionOpenResult, ErrorData> {
120    let profile_inputs = [
121        args.user_data_dir.as_ref().map(|_| "user_data_dir"),
122        args.profile_id.as_ref().map(|_| "profile_id"),
123        args.profile_pool.as_ref().map(|_| "profile_pool"),
124    ]
125    .into_iter()
126    .flatten()
127    .collect::<Vec<_>>();
128    if profile_inputs.len() > 1 {
129        return Err(ErrorData::invalid_params(
130            format!("profile inputs are mutually exclusive: {}", profile_inputs.join(", ")),
131            None,
132        ));
133    }
134
135    let mut builder = BrowserSession::builder();
136    builder = if args.headful { builder.headful() } else { builder.headless() };
137    if let Some(p) = args.port {
138        builder = builder.port(p);
139    }
140    if let Some(proxy) = args.proxy {
141        builder = builder.proxy(proxy);
142    }
143    let mut profile_lease: Option<ManagedProfileLease> = None;
144    let mut selected_profile_id: Option<String> = None;
145    if let Some(profile_id) = args.profile_id {
146        let lease = ProfileRegistry::default().acquire_profile(&profile_id).map_err(map_err)?;
147        selected_profile_id = Some(lease.id().to_string());
148        builder = builder.user_data_dir(lease.path());
149        profile_lease = Some(lease);
150    } else if let Some(pool_name) = args.profile_pool {
151        let lease = ProfileRegistry::default().acquire_from_pool(&pool_name).map_err(map_err)?;
152        selected_profile_id = Some(lease.id().to_string());
153        builder = builder.user_data_dir(lease.path());
154        profile_lease = Some(lease);
155    } else if let Some(path) = args.user_data_dir {
156        builder = builder.user_data_dir(expand_tilde(&path));
157    }
158    let session = builder.launch().await.map_err(map_err)?;
159    let page = session.new_blank_page().await.map_err(map_err)?;
160    // Read the attach coordinates before the session/page are moved into the
161    // handle, so callers can hand this exact tab to an external solver.
162    let websocket_url = session.websocket_url().await;
163    let target_id = page.target_id();
164    let id = Uuid::new_v4().to_string();
165    let handle = Arc::new(DedicatedSession {
166        session: Arc::new(session),
167        page: Mutex::new(page),
168        profile_lease,
169        last_navigation: Mutex::new(None),
170        challenge: Mutex::new(None),
171        pending_download: Mutex::new(None),
172    });
173    server.state().sessions.insert(id.clone(), handle).await;
174    Ok(SessionOpenResult {
175        session_id: id,
176        websocket_url,
177        port: args.port,
178        target_id,
179        profile_id: selected_profile_id,
180    })
181}
182
183pub async fn navigate(
184    server: &VoidCrawlServer,
185    args: SessionNavigateArgs,
186) -> Result<SessionNavigateResult, ErrorData> {
187    let handle = lookup(server, &args.session_id).await?;
188    let page = handle.page.lock().await;
189    let timeout = Duration::from_secs(args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
190    let resp = page.goto_and_wait_for_idle(&args.url, timeout).await.map_err(map_err)?;
191    wait::apply_post_navigate(&page, args.wait_for.as_deref(), timeout).await.map_err(map_err)?;
192    let last_navigation = LastNavigation {
193        url:         resp.url.clone(),
194        status_code: resp.status_code,
195        antibot:     resp.antibot.clone().filter(AntibotVerdict::detected),
196    };
197    *handle.last_navigation.lock().await = Some(last_navigation);
198    let antibot = resp.antibot.filter(AntibotVerdict::detected).map(AntibotInfo::from);
199    Ok(SessionNavigateResult {
200        url: resp.url,
201        status_code: resp.status_code,
202        redirected: resp.redirected,
203        antibot,
204    })
205}
206
207pub async fn content(
208    server: &VoidCrawlServer,
209    args: SessionIdArgs,
210) -> Result<SessionContentResult, ErrorData> {
211    let handle = lookup(server, &args.session_id).await?;
212    let page = handle.page.lock().await;
213    let html = page.content().await.map_err(map_err)?;
214    let title = page.title().await.ok().flatten();
215    let url = page.url().await.ok().flatten();
216    Ok(SessionContentResult { url, title, html })
217}
218
219pub async fn close(
220    server: &VoidCrawlServer,
221    args: SessionIdArgs,
222) -> Result<SessionCloseResult, ErrorData> {
223    let Some(handle) = server.state().sessions.remove(&args.session_id).await else {
224        return Ok(SessionCloseResult { closed: false });
225    };
226    close_handle(handle).await.map_err(map_err)?;
227    Ok(SessionCloseResult { closed: true })
228}
229
230async fn lookup(server: &VoidCrawlServer, id: &str) -> Result<Arc<DedicatedSession>, ErrorData> {
231    server
232        .state()
233        .sessions
234        .get(id)
235        .await
236        .ok_or_else(|| ErrorData::invalid_params(format!("unknown session_id: {id}"), None))
237}
238
239/// Shut down the browser backing a session.
240pub async fn close_handle(handle: Arc<DedicatedSession>) -> Result<(), VoidCrawlError> {
241    handle.session.close().await
242}
243
244/// Expand a leading `~/` or bare `~` using the `HOME` env var. Returns
245/// the input unchanged if `~` isn't leading or if `HOME` is unset —
246/// callers pass absolute paths, so either behaviour is a no-op in the
247/// common case.
248fn expand_tilde(path: &str) -> String {
249    let Some(rest) = path.strip_prefix('~') else { return path.to_owned() };
250    let Ok(home) = env::var("HOME") else { return path.to_owned() };
251    if rest.is_empty() {
252        home
253    } else if let Some(tail) = rest.strip_prefix('/') {
254        format!("{home}/{tail}")
255    } else {
256        path.to_owned()
257    }
258}