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::{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    /// Run headful (visible) instead of headless. Default is headless.
27    /// Set this to true if you want to log into a site manually in the
28    /// spawned Chrome window (pair with `user_data_dir` to persist).
29    #[serde(default)]
30    pub headful:       bool,
31    /// Optional proxy URL (e.g. "http://user:pass@host:port").
32    #[serde(default)]
33    pub proxy:         Option<String>,
34    /// Persistent Chrome profile directory. Omit for an ephemeral,
35    /// cookieless profile. Provide a path (e.g.
36    /// `~/.config/voidcrawl-linkedin`) to mount a profile across
37    /// sessions — log in once with `headful=true`, then subsequent
38    /// sessions reuse the cookie. Pick a path DEDICATED to voidcrawl;
39    /// Chrome locks a profile while running, so pointing at your
40    /// daily-driver profile while normal Chrome is open will fail.
41    #[serde(default)]
42    pub user_data_dir: Option<String>,
43    /// Pin Chrome's `--remote-debugging-port` so another process can attach to
44    /// this session's browser via the returned `websocket_url` (e.g. the
45    /// OpenSesame solver MCP, to solve a captcha on this exact tab). Omit to
46    /// let the OS pick a free ephemeral port — the `websocket_url` is
47    /// returned either way.
48    #[serde(default)]
49    pub port:          Option<u16>,
50}
51
52#[derive(Debug, Serialize, JsonSchema)]
53pub struct SessionOpenResult {
54    pub session_id:    String,
55    /// CDP WebSocket endpoint of this session's browser. Hand this together
56    /// with `target_id` to an external solver so it can attach to the *same*
57    /// Chrome and adopt this tab without opening a new one.
58    pub websocket_url: String,
59    /// The pinned remote-debugging port, if one was requested via `port`.
60    pub port:          Option<u16>,
61    /// CDP target id of this session's page — the exact tab to adopt.
62    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    /// "networkidle" (default) or "selector:<css>". Event-driven.
70    #[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    /// Anti-bot / CDN vendor fingerprint of the navigated response, or `null`
82    /// when no vendor was detected. See [`crate::tools::fetch::AntibotInfo`].
83    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    // Read the attach coordinates before the session/page are moved into the
121    // handle, so callers can hand this exact tab to an external solver.
122    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
184/// Shut down the browser backing a session.
185pub async fn close_handle(handle: Arc<DedicatedSession>) -> Result<(), VoidCrawlError> {
186    handle.session.close().await
187}
188
189/// Expand a leading `~/` or bare `~` using the `HOME` env var. Returns
190/// the input unchanged if `~` isn't leading or if `HOME` is unset —
191/// callers pass absolute paths, so either behaviour is a no-op in the
192/// common case.
193fn 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}