Skip to main content

voidcrawl_mcp/
state.rs

1//! Shared application state carried inside the MCP server.
2
3use std::{
4    env, fmt,
5    path::PathBuf,
6    str::FromStr,
7    sync::{Arc, Mutex as StdMutex},
8};
9
10use tokio::sync::OnceCell;
11use void_crawl_core::{
12    BrowserPool, BrowserSession, PoolConfig, ProfileHandle, Result, VoidCrawlError,
13};
14
15use crate::sessions::SessionRegistry;
16
17/// Pre-acquired profile that pins the whole server.
18///
19/// Built by `main.rs` when the user passes `--profile NAME` or sets
20/// `VOIDCRAWL_PROFILE`. The owned `BrowserSession` is moved into the
21/// pool on first `AppState::pool()` call so every `fetch` /
22/// `fetch_many` / `screenshot` tool call inherits the profile's
23/// cookies. The `ProfileHandle` is kept alive (inside the `AppState`)
24/// so its `fs2` advisory lock stays held for the server lifetime.
25pub struct PinnedProfile {
26    pub handle:         ProfileHandle,
27    /// Extracted once, at AppState::pool() first-call time.
28    pub session:        StdMutex<Option<BrowserSession>>,
29    /// Mirrors the profile's Chrome-facing name for user_data_dir
30    /// defaulting in `session_open`.
31    pub name:           String,
32    pub user_data_root: PathBuf,
33}
34
35impl fmt::Debug for PinnedProfile {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.debug_struct("PinnedProfile")
38            .field("name", &self.name)
39            .field("user_data_root", &self.user_data_root)
40            .finish_non_exhaustive()
41    }
42}
43
44/// Bundle of shared state passed into the `VoidCrawlServer`. Cheap to
45/// clone (two `Arc`s).
46///
47/// The `BrowserPool` is lazy: Chrome is not launched until the first tool
48/// call that needs it. This keeps the MCP `initialize` handshake fast so the
49/// harness can register tools without timing out on browser startup.
50#[derive(Debug, Clone)]
51pub struct AppState {
52    pool:         Arc<OnceCell<Arc<BrowserPool>>>,
53    pub sessions: Arc<SessionRegistry>,
54    pub pinned:   Option<Arc<PinnedProfile>>,
55}
56
57impl AppState {
58    pub fn new(sessions: Arc<SessionRegistry>) -> Self {
59        Self { pool: Arc::new(OnceCell::new()), sessions, pinned: None }
60    }
61
62    /// Construct with a pre-acquired profile that will back the pool.
63    pub fn with_pinned_profile(sessions: Arc<SessionRegistry>, pinned: PinnedProfile) -> Self {
64        Self { pool: Arc::new(OnceCell::new()), sessions, pinned: Some(Arc::new(pinned)) }
65    }
66
67    /// Construct with a pre-built pool already initialized. Useful for tests
68    /// that want to inject a specific `BrowserPool` instead of letting the
69    /// state launch Chrome itself.
70    pub fn with_pool(pool: Arc<BrowserPool>, sessions: Arc<SessionRegistry>) -> Self {
71        let cell = OnceCell::new_with(Some(pool));
72        Self { pool: Arc::new(cell), sessions, pinned: None }
73    }
74
75    /// Get the pool, launching Chrome on first call. Subsequent calls reuse
76    /// the same pool instance.
77    ///
78    /// When a profile is pinned, the pool is built from the pinned
79    /// session — `browsers=1` (Chrome's SingletonLock prevents a second
80    /// process on the same user_data_dir), `tabs_per_browser` still
81    /// gives real concurrency. Without a pinned profile, the old
82    /// `BrowserPool::from_env()` path runs unchanged.
83    pub async fn pool(&self) -> Result<Arc<BrowserPool>> {
84        let pinned = self.pinned.clone();
85        self.pool
86            .get_or_try_init(|| async move {
87                let pool = if let Some(p) = pinned {
88                    // Take the session out of the PinnedProfile. The
89                    // handle (and its lock guard) stays alive inside
90                    // `self.pinned` for the server lifetime.
91                    let session = p
92                        .session
93                        .lock()
94                        .map_err(|_| {
95                            VoidCrawlError::Other("PinnedProfile session mutex poisoned".into())
96                        })?
97                        .take()
98                        .ok_or_else(|| {
99                            VoidCrawlError::Other("PinnedProfile session already consumed".into())
100                        })?;
101                    Arc::new(BrowserPool::new(pool_config_from_env(), vec![session]))
102                } else {
103                    Arc::new(BrowserPool::from_env().await?)
104                };
105                Arc::clone(&pool).start_eviction_task();
106                Ok(pool)
107            })
108            .await
109            .map(Arc::clone)
110    }
111
112    /// Returns the initialized pool, if any. Used on shutdown to close without
113    /// forcing a launch.
114    pub fn pool_if_initialized(&self) -> Option<Arc<BrowserPool>> {
115        self.pool.get().map(Arc::clone)
116    }
117}
118
119/// Read the subset of `PoolConfig` values from env that apply to a
120/// pinned-profile pool. Matches `BrowserPool::from_env` for these
121/// knobs but ignores `BROWSER_COUNT` / `CHROME_WS_URLS` (single Chrome,
122/// no attach).
123fn pool_config_from_env() -> PoolConfig {
124    fn parse<T: FromStr>(key: &str, default: T) -> T {
125        env::var(key).ok().and_then(|s| s.parse().ok()).unwrap_or(default)
126    }
127    PoolConfig {
128        browsers:             1,
129        tabs_per_browser:     parse("TABS_PER_BROWSER", 4),
130        tab_max_uses:         parse("TAB_MAX_USES", 50),
131        tab_max_idle_secs:    parse("TAB_MAX_IDLE_SECS", 60),
132        acquire_timeout_secs: parse("ACQUIRE_TIMEOUT_SECS", 30),
133        auto_evict:           env::var("AUTO_EVICT").map_or(true, |v| v != "0"),
134    }
135}