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::{BrowserSession, VoidCrawlError};
14
15use crate::{errors::map_err, server::VoidCrawlServer, sessions::DedicatedSession, tools::wait};
16
17pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
18
19#[derive(Debug, Deserialize, JsonSchema, Default)]
20pub struct SessionOpenArgs {
21    /// Run headful (visible) instead of headless. Default is headless.
22    /// Set this to true if you want to log into a site manually in the
23    /// spawned Chrome window (pair with `user_data_dir` to persist).
24    #[serde(default)]
25    pub headful:       bool,
26    /// Optional proxy URL (e.g. "http://user:pass@host:port").
27    #[serde(default)]
28    pub proxy:         Option<String>,
29    /// Persistent Chrome profile directory. Omit for an ephemeral,
30    /// cookieless profile. Provide a path (e.g.
31    /// `~/.config/voidcrawl-linkedin`) to mount a profile across
32    /// sessions — log in once with `headful=true`, then subsequent
33    /// sessions reuse the cookie. Pick a path DEDICATED to voidcrawl;
34    /// Chrome locks a profile while running, so pointing at your
35    /// daily-driver profile while normal Chrome is open will fail.
36    #[serde(default)]
37    pub user_data_dir: Option<String>,
38}
39
40#[derive(Debug, Serialize, JsonSchema)]
41pub struct SessionOpenResult {
42    pub session_id: String,
43}
44
45#[derive(Debug, Deserialize, JsonSchema, Default)]
46pub struct SessionNavigateArgs {
47    pub session_id:   String,
48    pub url:          String,
49    /// "networkidle" (default) or "selector:<css>". Event-driven.
50    #[serde(default)]
51    pub wait_for:     Option<String>,
52    #[serde(default)]
53    pub timeout_secs: Option<u64>,
54}
55
56#[derive(Debug, Serialize, JsonSchema)]
57pub struct SessionNavigateResult {
58    pub url:         String,
59    pub status_code: Option<u16>,
60    pub redirected:  bool,
61}
62
63#[derive(Debug, Deserialize, JsonSchema, Default)]
64pub struct SessionIdArgs {
65    pub session_id: String,
66}
67
68#[derive(Debug, Serialize, JsonSchema)]
69pub struct SessionContentResult {
70    pub url:   Option<String>,
71    pub title: Option<String>,
72    pub html:  String,
73}
74
75#[derive(Debug, Serialize, JsonSchema)]
76pub struct SessionCloseResult {
77    pub closed: bool,
78}
79
80pub async fn open(
81    server: &VoidCrawlServer,
82    args: SessionOpenArgs,
83) -> Result<SessionOpenResult, ErrorData> {
84    let mut builder = BrowserSession::builder();
85    builder = if args.headful { builder.headful() } else { builder.headless() };
86    if let Some(proxy) = args.proxy {
87        builder = builder.proxy(proxy);
88    }
89    if let Some(path) = args.user_data_dir {
90        builder = builder.user_data_dir(expand_tilde(&path));
91    }
92    let session = builder.launch().await.map_err(map_err)?;
93    let page = session.new_blank_page().await.map_err(map_err)?;
94    let id = Uuid::new_v4().to_string();
95    let handle =
96        Arc::new(DedicatedSession { session: Arc::new(session), page: Mutex::new(page) });
97    server.state().sessions.insert(id.clone(), handle).await;
98    Ok(SessionOpenResult { session_id: id })
99}
100
101pub async fn navigate(
102    server: &VoidCrawlServer,
103    args: SessionNavigateArgs,
104) -> Result<SessionNavigateResult, ErrorData> {
105    let handle = lookup(server, &args.session_id).await?;
106    let page = handle.page.lock().await;
107    let timeout = Duration::from_secs(args.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
108    let resp = page.goto_and_wait_for_idle(&args.url, timeout).await.map_err(map_err)?;
109    wait::apply_post_navigate(&page, args.wait_for.as_deref(), timeout).await.map_err(map_err)?;
110    Ok(SessionNavigateResult {
111        url:         resp.url,
112        status_code: resp.status_code,
113        redirected:  resp.redirected,
114    })
115}
116
117pub async fn content(
118    server: &VoidCrawlServer,
119    args: SessionIdArgs,
120) -> Result<SessionContentResult, ErrorData> {
121    let handle = lookup(server, &args.session_id).await?;
122    let page = handle.page.lock().await;
123    let html = page.content().await.map_err(map_err)?;
124    let title = page.title().await.ok().flatten();
125    let url = page.url().await.ok().flatten();
126    Ok(SessionContentResult { url, title, html })
127}
128
129pub async fn close(
130    server: &VoidCrawlServer,
131    args: SessionIdArgs,
132) -> Result<SessionCloseResult, ErrorData> {
133    let Some(handle) = server.state().sessions.remove(&args.session_id).await else {
134        return Ok(SessionCloseResult { closed: false });
135    };
136    close_handle(handle).await.map_err(map_err)?;
137    Ok(SessionCloseResult { closed: true })
138}
139
140async fn lookup(server: &VoidCrawlServer, id: &str) -> Result<Arc<DedicatedSession>, ErrorData> {
141    server
142        .state()
143        .sessions
144        .get(id)
145        .await
146        .ok_or_else(|| ErrorData::invalid_params(format!("unknown session_id: {id}"), None))
147}
148
149/// Shut down the browser backing a session.
150pub async fn close_handle(handle: Arc<DedicatedSession>) -> Result<(), VoidCrawlError> {
151    handle.session.close().await
152}
153
154/// Expand a leading `~/` or bare `~` using the `HOME` env var. Returns
155/// the input unchanged if `~` isn't leading or if `HOME` is unset —
156/// callers pass absolute paths, so either behaviour is a no-op in the
157/// common case.
158fn expand_tilde(path: &str) -> String {
159    let Some(rest) = path.strip_prefix('~') else { return path.to_owned() };
160    let Ok(home) = env::var("HOME") else { return path.to_owned() };
161    if rest.is_empty() {
162        home
163    } else if let Some(tail) = rest.strip_prefix('/') {
164        format!("{home}/{tail}")
165    } else {
166        path.to_owned()
167    }
168}