Skip to main content

browser_control/mcp/
server.rs

1//! Minimal hand-rolled MCP JSON-RPC server over stdio.
2//!
3//! This is the wave-3 skeleton. A future task may replace this with a more
4//! capable framework (e.g. `rmcp`). The protocol surface is small:
5//! newline-delimited JSON-RPC 2.0 over stdin/stdout.
6
7use anyhow::{Context, Result};
8use serde_json::{json, Value};
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
12use tokio::sync::{Mutex, RwLock};
13
14use crate::cli::env_resolver::ResolvedBrowser;
15use crate::session::backend::TabBackend;
16
17/// Persistent BiDi client, opened lazily on first use. Reused across all
18/// tool calls because Firefox limits concurrent BiDi sessions per browser
19/// to one. The browsing-context id is resolved per call so multiple tabs
20/// can be addressed once URL-regex selection is added.
21pub type BidiCache = Arc<Mutex<Option<Arc<crate::bidi::BidiClient>>>>;
22
23/// State carried by the server. Tools reach into this for the resolved
24/// browser endpoint and any cached engine clients.
25///
26/// `browser` is `RwLock`-wrapped so `browser_select` can swap the active
27/// browser at runtime; readers take a brief read lock and clone out the
28/// value they need (the struct is cheap to clone).
29///
30/// `active_target_id` is the in-memory pointer to the MCP server's
31/// "current tab" — replaces the SQLite `_mcp-<pid>` row pattern. The
32/// pointer is lazy-initialised on first stateful tool call and updated
33/// by `browser_tab_*` and `browser_select`.
34#[derive(Clone)]
35pub struct ServerState {
36    pub browser: Arc<RwLock<ResolvedBrowser>>,
37    pub bidi: BidiCache,
38    /// Firefox BiDi single-session lock, acquired lazily on first tool
39    /// call and held for the server's lifetime. `None` for CDP browsers
40    /// and external endpoints (where `acquire_bidi_lock_if_needed`
41    /// returns None) — the inner `Option<BidiLockGuard>` distinguishes
42    /// "haven't tried yet" from "tried, not applicable" via the outer
43    /// `Mutex` being unlocked vs returning None.
44    pub bidi_lock: Arc<Mutex<BidiLockState>>,
45    /// Cached [`TabBackend`] for the configured browser, opened lazily
46    /// on first tool call and reused for the server's lifetime. Avoids
47    /// repeatedly running the BiDi `session.new` handshake and lets us
48    /// share one CDP WebSocket across all tool calls.
49    pub backend: Arc<Mutex<Option<TabBackend>>>,
50    /// In-memory "active tab" pointer. `None` until lazy-init by
51    /// `current_tab()` or set explicitly by `browser_tab_select` /
52    /// `browser_tab_new`. Cleared on `browser_tab_close` (when closing
53    /// the active tab) and on `browser_select`.
54    pub active_target_id: Arc<Mutex<Option<String>>>,
55    /// MCP-owned origin tabs created by bare `browser_fetch`, keyed by
56    /// requested origin root (`https://example.com/`). This supplements
57    /// URL-based live-target matching so a tab that redirects to a login
58    /// origin after token expiry is still reused on later fetches instead
59    /// of creating one new tab per retry.
60    pub origin_target_ids: Arc<Mutex<HashMap<String, String>>>,
61    /// Lazy-spawned Playwright sidecar for the Chromium-only interaction
62    /// tools (`browser_click`, `browser_snapshot`, etc.). One sidecar
63    /// per server-per-browser; `browser_select` disposes the old one
64    /// and the next sidecar-using tool spawns a fresh one against the
65    /// new endpoint. `None` for BiDi browsers (the sidecar tools error
66    /// with `EngineUnsupported`) and on fresh servers until first use.
67    pub sidecar: Arc<Mutex<Option<crate::sidecar::Sidecar>>>,
68    /// Sidecar config (Playwright version override etc.) — set once at
69    /// server startup from CLI args, read on each sidecar spawn.
70    pub sidecar_config: crate::sidecar::SidecarConfig,
71    /// Element refs handed out by `browser_snapshot` / `browser_find`,
72    /// keyed by target id. Each table is bound to one document (see
73    /// [`crate::a11y::RefTable::doc_token`]); a navigation replaces it.
74    /// Cleared on `browser_select`; the entry for a tab is dropped when
75    /// `browser_tab_close` closes it.
76    pub refs: Arc<Mutex<HashMap<String, crate::a11y::RefTable>>>,
77    /// Passive console/network capture for every tab a tool call has
78    /// touched (see [`crate::session::capture`]). Reset on `browser_select`.
79    pub capture: Arc<crate::session::capture::CaptureHub>,
80    /// Download management (ADR-005): silent downloads into the staging
81    /// folder for browser-control-launched browsers, plus the records
82    /// `browser_downloads` reads. Reset on `browser_select`.
83    pub downloads: Arc<crate::session::downloads::DownloadHub>,
84    /// Default destination for `browser_downloads` `wait` / `save`
85    /// (`--download-dir` or `BROWSER_CONTROL_DOWNLOAD_DIR`). `None` keeps
86    /// files in the staging folder.
87    pub download_dir: Option<std::path::PathBuf>,
88    /// Operation barrier. Non-exclusive tool calls acquire a **read**
89    /// guard so they can run concurrently; `switch_browser` acquires a
90    /// **write** guard which waits for all in-flight tool operations to
91    /// finish, preventing the old backend / BiDi session from being
92    /// torn down while another tool is still using it.
93    ///
94    /// `browser_select` is the only tool that needs exclusive access
95    /// (via `switch_browser`); `handle_tools_call` skips the read
96    /// guard for it to avoid deadlocking with its own write guard.
97    pub op_barrier: Arc<RwLock<()>>,
98}
99
100/// Three-state cache: `Pending` until first tool call attempts acquire;
101/// `Acquired` holding the guard; `NotApplicable` for CDP / external
102/// endpoints where no lock is needed.
103#[derive(Default)]
104pub enum BidiLockState {
105    #[default]
106    Pending,
107    Acquired(crate::registry::BidiLockGuard),
108    NotApplicable,
109}
110
111impl ServerState {
112    pub fn new(browser: ResolvedBrowser) -> Self {
113        Self::with_sidecar_config(browser, crate::sidecar::SidecarConfig::default())
114    }
115
116    /// Construct a `ServerState` with a non-default sidecar config (e.g.
117    /// a custom Playwright version from `--playwright-version`).
118    pub fn with_sidecar_config(
119        browser: ResolvedBrowser,
120        sidecar_config: crate::sidecar::SidecarConfig,
121    ) -> Self {
122        Self {
123            browser: Arc::new(RwLock::new(browser)),
124            bidi: Arc::new(Mutex::new(None)),
125            bidi_lock: Arc::new(Mutex::new(BidiLockState::Pending)),
126            backend: Arc::new(Mutex::new(None)),
127            active_target_id: Arc::new(Mutex::new(None)),
128            origin_target_ids: Arc::new(Mutex::new(HashMap::new())),
129            sidecar: Arc::new(Mutex::new(None)),
130            sidecar_config,
131            refs: Arc::new(Mutex::new(HashMap::new())),
132            capture: Arc::new(crate::session::capture::CaptureHub::new()),
133            downloads: Arc::new(crate::session::downloads::DownloadHub::new()),
134            download_dir: None,
135            op_barrier: Arc::new(RwLock::new(())),
136        }
137    }
138
139    /// Engine gate for `browser_network_body`. The console/network listing
140    /// tools work on both engines (CDP domains or a BiDi subscription) and
141    /// need no gate; bodies require CDP `Network.getResponseBody`.
142    pub async fn ensure_body_capture_supported(&self, tool_name: &str) -> Result<()> {
143        self.ensure_cdp_engine(tool_name, crate::session::capture::BIDI_NO_BODIES_HINT)
144            .await
145    }
146
147    /// Validate that the active browser speaks CDP, without touching the
148    /// backend. `hint` steers the agent to the engine-agnostic alternative.
149    pub async fn ensure_cdp_engine(&self, tool_name: &str, hint: &'static str) -> Result<()> {
150        self.ensure_active_browser_alive().await?;
151        let resolved = self.browser_snapshot().await;
152        if resolved.engine != crate::detect::Engine::Cdp {
153            return Err(crate::errors::SessionError::EngineUnsupported {
154                tool: tool_name.to_string(),
155                required_engine: "Chromium (CDP)".into(),
156                current_engine: format!("{:?}", resolved.engine),
157                hint,
158            }
159            .into());
160        }
161        Ok(())
162    }
163
164    /// Preflight for the native ref-based tools (`browser_snapshot`,
165    /// `browser_find`, `ref` on click/type/hover/drag/screenshot). Both
166    /// engines are supported (CDP accessibility tree + `Input.*`, or the
167    /// BiDi DOM walker + `input.performActions`), so this only checks that
168    /// the active browser is still alive before any protocol I/O.
169    pub async fn ensure_native_ready(&self, _tool_name: &str) -> Result<()> {
170        self.ensure_active_browser_alive().await
171    }
172
173    /// Lazy-spawn the Playwright sidecar against the current browser.
174    /// Errors with `EngineUnsupported` when the active browser is BiDi
175    /// (Playwright can't drive a user-launched Firefox over BiDi/CDP).
176    ///
177    /// Idempotent: subsequent calls return the cached handle. The handle
178    /// is dropped (and the child killed) when `switch_browser` clears it.
179    pub async fn ensure_sidecar(&self, tool_name: &str) -> Result<crate::sidecar::Sidecar> {
180        self.ensure_sidecar_supported(tool_name).await?;
181        let resolved = self.browser_snapshot().await;
182        let mut guard = self.sidecar.lock().await;
183        if let Some(sc) = guard.as_ref() {
184            return Ok(sc.clone());
185        }
186        let sc = crate::sidecar::Sidecar::start(self.sidecar_config.clone()).await?;
187        sc.connect(&resolved.endpoint).await?;
188        *guard = Some(sc.clone());
189        drop(guard);
190        // Playwright's connectOverCDP replaces the download behavior with
191        // its own temp dir (GUID names, deleted on disconnect); put ours
192        // back immediately.
193        let backend = self.backend.lock().await.clone();
194        if let Some(b) = backend {
195            self.ensure_downloads(&b, true).await;
196        }
197        Ok(sc)
198    }
199
200    /// Validate that the active browser can use the Playwright sidecar without
201    /// spawning Node or opening a Playwright CDP connection.
202    pub async fn ensure_sidecar_supported(&self, tool_name: &str) -> Result<()> {
203        self.ensure_cdp_engine(
204            tool_name,
205            "use engine-agnostic tools such as browser_get_html, browser_fetch, browser_take_screenshot, or switch to a Chromium browser via browser_select",
206        )
207        .await
208    }
209
210    /// Drop the cached Playwright sidecar after a connection-layer failure.
211    /// Drop kills the child through `SidecarInner`; avoid a best-effort
212    /// `dispose` RPC here because the sidecar may be exactly what is wedged.
213    pub async fn reset_sidecar(&self) {
214        let mut sidecar = self.sidecar.lock().await;
215        let _ = sidecar.take();
216        // The sidecar's connection set a download behavior; Chromium may
217        // reset it to the profile default when that connection closes.
218        self.downloads.invalidate();
219    }
220
221    /// Snapshot the current resolved browser (cheap clone of a small struct).
222    pub async fn browser_snapshot(&self) -> ResolvedBrowser {
223        self.browser.read().await.clone()
224    }
225
226    /// Ensure the BiDi single-session lock is held (if applicable).
227    /// Lazy + idempotent: called by each tool handler before opening a
228    /// BiDi session, returns immediately on second+ calls.
229    pub async fn ensure_bidi_lock(&self) -> Result<()> {
230        use crate::cli::env_resolver::Source;
231        use crate::cli::mcp::acquire_bidi_lock_if_needed;
232        use crate::detect::Engine;
233        use crate::registry::Registry;
234        let mut guard = self.bidi_lock.lock().await;
235        if matches!(*guard, BidiLockState::Pending) {
236            let resolved = self.browser_snapshot().await;
237            if resolved.engine != Engine::Bidi || matches!(resolved.source, Source::External) {
238                *guard = BidiLockState::NotApplicable;
239                return Ok(());
240            }
241            // `bidi_lock_acquire` polls with a blocking `std::thread::sleep`
242            // for up to 30s under contention. Run it on a blocking thread so
243            // we never park a tokio worker (mirrors `resolve_browser_send`).
244            // `Registry` is opened fresh inside and `resolved` is an owned
245            // clone, so both move into the closure.
246            let acquired = tokio::task::spawn_blocking(move || {
247                let registry = Registry::open()?;
248                acquire_bidi_lock_if_needed(&registry, &resolved)
249            })
250            .await??;
251            *guard = match acquired {
252                Some(lock) => BidiLockState::Acquired(lock),
253                None => BidiLockState::NotApplicable,
254            };
255        }
256        Ok(())
257    }
258
259    /// Lazy-open (or return cached) [`TabBackend`] for the server's
260    /// browser. Acquires the BiDi lock first if applicable. The backend
261    /// is cached for the server's lifetime so the BiDi `session.new`
262    /// handshake runs once and the CDP WebSocket is reused across calls.
263    pub async fn ensure_backend(&self) -> Result<TabBackend> {
264        self.ensure_active_browser_alive().await?;
265        self.ensure_bidi_lock().await?;
266        let b = {
267            let mut guard = self.backend.lock().await;
268            match guard.as_ref() {
269                Some(b) => b.clone(),
270                None => {
271                    let resolved = self.browser_snapshot().await;
272                    let b =
273                        crate::session::backend::open_backend(&resolved.endpoint, resolved.engine)
274                            .await?;
275                    *guard = Some(b.clone());
276                    b
277                }
278            }
279        };
280        self.ensure_downloads(&b, false).await;
281        Ok(b)
282    }
283
284    /// Mark a tab as used by this server: start passive capture and
285    /// attribute its downloads to this server (ADR-005).
286    pub fn touch_tab(&self, backend: &TabBackend, target_id: &str) {
287        self.downloads.touch(target_id);
288        self.capture.touch(backend, target_id);
289    }
290
291    /// Resolve the download mode for `backend` once, then (re)send the
292    /// runtime download override (debounced unless `force`). Re-sending
293    /// on every tool call is what heals the reset Chromium performs when
294    /// another connection that set the behavior detaches (ADR-005).
295    /// Never errors.
296    pub async fn ensure_downloads(&self, backend: &TabBackend, force: bool) {
297        use crate::cli::env_resolver::Source;
298        use crate::session::downloads::{Mode, UNMANAGED_ATTACHED};
299        if self.downloads.mode_for(backend).is_none() {
300            let resolved = self.browser_snapshot().await;
301            let mode = match resolved.source {
302                Source::External => Mode::Unmanaged {
303                    reason: UNMANAGED_ATTACHED,
304                },
305                Source::Registered { name } => match sync_registry_op(move |reg| {
306                    let Some(row) = reg.get_by_name(&name)? else {
307                        return Ok(Mode::Unmanaged {
308                            reason: UNMANAGED_ATTACHED,
309                        });
310                    };
311                    if !crate::paths::is_owned_profile(&row.profile_dir) {
312                        return Ok(Mode::Unmanaged {
313                            reason: UNMANAGED_ATTACHED,
314                        });
315                    }
316                    Ok(Mode::Managed {
317                        staging: crate::paths::downloads_staging_dir(row.kind)?,
318                    })
319                })
320                .await
321                {
322                    Ok(mode) => mode,
323                    Err(e) => {
324                        // Transient (e.g. SQLite busy): leave the mode unset
325                        // so the next call retries instead of caching
326                        // "unmanaged" for the backend's lifetime.
327                        tracing::debug!(error = %e, "resolving download mode failed");
328                        return;
329                    }
330                },
331            };
332            self.downloads.set_mode(backend, mode);
333        }
334        self.downloads.apply(backend, force).await;
335    }
336
337    /// Check that the active registered browser is still usable before a tool
338    /// attempts protocol I/O. This keeps terminated-browser recovery
339    /// actionable for agents: call `browser_start` to launch/reuse a browser
340    /// or `browser_select` to switch to another live one.
341    ///
342    /// External URL endpoints have no registry identity, so they are checked
343    /// by the transport layer.
344    pub async fn ensure_active_browser_alive(&self) -> Result<()> {
345        use crate::cli::env_resolver::Source;
346        use crate::registry::BrowserLiveness;
347
348        let resolved = self.browser_snapshot().await;
349        let Source::Registered { name } = resolved.source else {
350            return Ok(());
351        };
352        sync_registry_op(move |registry| {
353            let Some(row) = registry
354                .get_by_name(&name)
355                .with_context(|| format!("checking active browser `{name}`"))?
356            else {
357                let hint = "call `browser_start` to start a browser, or `browser_select` to switch to another live browser";
358                let kind_hint = generated_kind_hint(&name);
359                anyhow::bail!(
360                    "active browser `{name}` is no longer registered{kind_hint}; {hint}"
361                );
362            };
363            match crate::registry::liveness(&row) {
364                BrowserLiveness::Alive => Ok(()),
365                BrowserLiveness::DeadPid => {
366                    registry
367                        .delete(&row.name)
368                        .with_context(|| format!("pruning terminated browser {}", row.name))?;
369                    anyhow::bail!(
370                        "active browser `{}` has exited (pid {}); call `browser_start` with `{}` to launch/reuse a browser, or `browser_select` another live browser",
371                        row.name,
372                        row.pid,
373                        row.kind.as_str()
374                    );
375                }
376                BrowserLiveness::EndpointUnreachable => {
377                    anyhow::bail!(
378                        "active browser `{}` is not reachable at {} (pid {} still exists); retry, call `browser_start` with `{}` to launch/reuse a browser, or `browser_select` another live browser",
379                        row.name,
380                        row.endpoint,
381                        row.pid,
382                        row.kind.as_str()
383                    );
384                }
385            }
386        })
387        .await
388    }
389
390    /// Resolve the MCP server's "active tab" — backed by an in-memory
391    /// `active_target_id` pointer rather than a SQLite row.
392    ///
393    /// The returned `(backend, target_id)` is the routing pair stateful
394    /// MCP tools (`browser_navigate`, `browser_get_html`, …) use when no
395    /// explicit `tab` / `target` arg is given.
396    ///
397    /// Behaviour:
398    /// - **None** → create an `about:blank` and store it.
399    /// - **Set but dead** (no longer in `live_target_ids`) → recreate
400    ///   `about:blank` and re-point the pointer. This is the scratch-style
401    ///   implicit recovery for the **server-owned** active tab; explicit
402    ///   tabs created via `browser_tab_new` / `browser_tab_select` also
403    ///   travel through here once they become the active tab, but
404    ///   recovery there means the agent-named tab is gone — see
405    ///   `browser_tab_select`'s dead-tab handling for the explicit-select
406    ///   contract.
407    /// - **Set and alive** → return as-is.
408    pub async fn current_tab(&self) -> Result<(TabBackend, String)> {
409        let backend = self.ensure_backend().await?;
410        let mut pointer = self.active_target_id.lock().await;
411        if let Some(tid) = pointer.as_ref() {
412            let live = backend.live_target_ids().await?;
413            if live.contains(tid) {
414                self.touch_tab(&backend, tid);
415                return Ok((backend, tid.clone()));
416            }
417            // Dead — fall through to recreate.
418        }
419        let new_tid = backend.create_tab("about:blank").await?;
420        *pointer = Some(new_tid.clone());
421        self.touch_tab(&backend, &new_tid);
422        Ok((backend, new_tid))
423    }
424
425    /// Resolve or create an MCP-owned tab for a fetch URL's origin.
426    ///
427    /// `TabBackend::resolve_or_create_for_origin` can only reuse targets
428    /// whose current browser URL still has the requested origin. During auth
429    /// expiry, an origin tab may redirect to an identity provider or login
430    /// route; if we only inspect current URLs, each retry can create another
431    /// tab. This cache records the target originally allocated for each
432    /// requested origin and reuses it while it is still live.
433    pub async fn resolve_or_create_for_origin(&self, url: &str) -> Result<(TabBackend, String)> {
434        let want =
435            url::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid fetch URL `{url}`: {e}"))?;
436        let origin_root = crate::session::attach::origin_root_url(&want);
437        let backend = self.ensure_backend().await?;
438        let mut origin_targets = self.origin_target_ids.lock().await;
439        let live_targets = backend.live_targets().await?;
440        let live_ids: std::collections::HashSet<&str> =
441            live_targets.iter().map(|t| t.id.as_str()).collect();
442
443        if let Some(cached) = origin_targets.get(&origin_root) {
444            if live_ids.contains(cached.as_str()) {
445                self.touch_tab(&backend, cached);
446                return Ok((backend, cached.clone()));
447            }
448            origin_targets.remove(&origin_root);
449        }
450
451        if let Some(existing) = live_targets.iter().find(|t| {
452            url::Url::parse(&t.url)
453                .map(|parsed| crate::session::attach::same_origin(&parsed, &want))
454                .unwrap_or(false)
455        }) {
456            origin_targets.insert(origin_root, existing.id.clone());
457            self.touch_tab(&backend, &existing.id);
458            return Ok((backend, existing.id.clone()));
459        }
460
461        let new_tid = backend.create_tab(&origin_root).await?;
462        origin_targets.insert(origin_root, new_tid.clone());
463        self.touch_tab(&backend, &new_tid);
464        Ok((backend, new_tid))
465    }
466
467    /// Route a stateful tool call to a backend + target id based on the
468    /// optional `tab` (named) and `target` (URL regex) args. `tab` and
469    /// `target` are mutually exclusive. Falls through to `current_tab()`
470    /// when neither is provided.
471    ///
472    /// For the named-tab path: the registered tab row is resolved (with
473    /// sweep-on-read for stale rows) and returned. Tools that want
474    /// recover-on-failure semantics should structure their op around the
475    /// returned `(backend, target_id)` — full `with_named_tab_recovery`
476    /// can't run from a `Send` MCP future because `Registry` is `!Send`.
477    ///
478    /// For the URL-regex path, probe-and-iterate via the live targets
479    /// snapshot. Surfaces `SessionError::TabHung` if every match is
480    /// unresponsive within a 500ms probe.
481    pub async fn resolve_target_for_args(&self, args: &Value) -> Result<(TabBackend, String)> {
482        let tab = args.get("tab").and_then(|v| v.as_str()).map(String::from);
483        let target = args
484            .get("target")
485            .and_then(|v| v.as_str())
486            .map(String::from);
487        match (tab, target) {
488            (Some(_), Some(_)) => Err(anyhow::anyhow!("`tab` and `target` are mutually exclusive")),
489            (Some(name), None) => {
490                let backend = self.ensure_backend().await?;
491                let browser_name = self.registered_browser_name().await?;
492                // Mimic `session::tabs::resolve_tab` here so we never hold
493                // a `!Send` `Registry` across `.await`: sync registry-read,
494                // async liveness probe, sync registry-mutate.
495                let bn = browser_name.clone();
496                let n = name.clone();
497                let row = sync_registry_op(move |reg| reg.tab_get(&bn, &n))
498                    .await?
499                    .ok_or_else(|| crate::errors::SessionError::TabNotFound {
500                        browser: browser_name.clone(),
501                        name: name.clone(),
502                    })?;
503                let live = backend.live_target_ids().await?;
504                if !live.contains(&row.target_id) {
505                    // Stale — sweep, then error.
506                    let bn = browser_name.clone();
507                    let n = name.clone();
508                    sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
509                    return Err(crate::errors::SessionError::TabNotFound {
510                        browser: browser_name,
511                        name,
512                    }
513                    .into());
514                }
515                let bn = browser_name.clone();
516                let n = name.clone();
517                sync_registry_op(move |reg| reg.tab_touch(&bn, &n)).await?;
518                self.touch_tab(&backend, &row.target_id);
519                Ok((backend, row.target_id))
520            }
521            (None, Some(regex)) => {
522                let backend = self.ensure_backend().await?;
523                let target_id = resolve_target_by_regex(&backend, &regex).await?;
524                self.touch_tab(&backend, &target_id);
525                Ok((backend, target_id))
526            }
527            (None, None) => self.current_tab().await,
528        }
529    }
530
531    /// The registered browser's name. Errors if the active browser is an
532    /// external URL endpoint (no stable identity for named tabs).
533    pub async fn registered_browser_name(&self) -> Result<String> {
534        use crate::cli::env_resolver::Source;
535        let resolved = self.browser_snapshot().await;
536        match resolved.source {
537            Source::Registered { name } => Ok(name),
538            Source::External => Err(anyhow::anyhow!(
539                "operation requires a registered browser; external URL endpoints \
540                 don't have a stable identity"
541            )),
542        }
543    }
544
545    /// Swap the active browser. Drops the cached backend and BiDi session,
546    /// releases the BiDi lock (if held), clears the active tab pointer,
547    /// then installs the new browser and re-acquires the BiDi lock if
548    /// the new one needs it. The next stateful tool call lazy-opens the
549    /// new backend.
550    ///
551    /// # Concurrency
552    /// The caller must hold a **write** guard on [`Self::op_barrier`] to
553    /// ensure no concurrent tool call is still using the old backend.
554    /// [`handle_tools_call`] acquires the write guard for `browser_select`
555    /// before invoking this method.
556    pub async fn switch_browser(&self, new_browser: ResolvedBrowser) -> Result<()> {
557        // Capture state belongs to the old browser's sessions; dropping the
558        // backend below closes the socket, so no detach RPCs are needed.
559        self.capture.reset();
560        // Records survive a re-select of the same browser (agents call
561        // `browser_start` defensively); the new backend re-arms routing.
562        if self.browser_snapshot().await.endpoint != new_browser.endpoint {
563            self.downloads.reset();
564        }
565        // Close the cached BiDi session if any (best-effort).
566        {
567            let mut bidi = self.bidi.lock().await;
568            if let Some(client) = bidi.take() {
569                let _ = client.session_end().await;
570            }
571        }
572        // Release the engine session and drop the cached backend so the
573        // next call rebuilds against the new browser.
574        {
575            let old = self.backend.lock().await.take();
576            if let Some(b) = old {
577                b.shutdown().await;
578            }
579        }
580        // Release the BiDi lock guard (Drop releases it) and reset to Pending.
581        {
582            let mut lock = self.bidi_lock.lock().await;
583            *lock = BidiLockState::Pending;
584        }
585        // Clear the active tab pointer.
586        {
587            let mut pointer = self.active_target_id.lock().await;
588            *pointer = None;
589        }
590        // Clear origin-bound fetch target cache.
591        {
592            let mut origins = self.origin_target_ids.lock().await;
593            origins.clear();
594        }
595        // Element refs belong to the old browser's documents.
596        {
597            let mut refs = self.refs.lock().await;
598            refs.clear();
599        }
600        // Dispose the Playwright sidecar — different browser means
601        // different CDP endpoint; the next sidecar tool spawns a fresh
602        // child connected to the new endpoint.
603        {
604            let mut sidecar = self.sidecar.lock().await;
605            if let Some(sc) = sidecar.take() {
606                let _ = sc.call("dispose", serde_json::json!({})).await;
607                // Drop releases the child via SidecarInner::drop.
608                drop(sc);
609            }
610        }
611        // Install the new browser.
612        {
613            let mut br = self.browser.write().await;
614            *br = new_browser;
615        }
616        // Eagerly re-acquire the BiDi lock if applicable, so any error
617        // surfaces here rather than at the next tool call.
618        self.ensure_bidi_lock().await?;
619        Ok(())
620    }
621}
622
623/// Helper that opens a fresh `Registry` and runs a closure against it,
624/// returning the result. Used by the MCP layer to keep `!Send`
625/// `rusqlite::Connection` references off of `.await`-crossing scopes.
626///
627/// The closure runs on a blocking thread via `tokio::task::spawn_blocking`
628/// (mirroring `resolve_browser_send`) so the synchronous `rusqlite` work
629/// and the blocking exclusive `flock` taken across schema migration in
630/// `Registry::open` never park a tokio worker. The `!Send` `Registry` is
631/// created and dropped entirely inside the closure, so it never crosses an
632/// `.await`.
633pub(crate) async fn sync_registry_op<T, F>(f: F) -> Result<T>
634where
635    F: FnOnce(&crate::registry::Registry) -> Result<T> + Send + 'static,
636    T: Send + 'static,
637{
638    tokio::task::spawn_blocking(move || {
639        let reg = crate::registry::Registry::open()?;
640        f(&reg)
641    })
642    .await?
643}
644
645fn generated_kind_hint(name: &str) -> String {
646    let Some((prefix, _)) = name.split_once('-') else {
647        return String::new();
648    };
649    if crate::detect::Kind::parse(prefix).is_some() {
650        format!("; it looks like a generated `{prefix}` browser name")
651    } else {
652        String::new()
653    }
654}
655
656/// Resolve a `BrowserSelector` to a `ResolvedBrowser` from a `Send`
657/// async context. The URL branch awaits an HTTP roundtrip (no registry
658/// needed); the registered/kind/path branches run synchronously via
659/// `tokio::task::spawn_blocking` so the `!Send` `Registry` never sits
660/// across `.await`.
661pub(crate) async fn resolve_browser_send(
662    selector: crate::cli::env_resolver::BrowserSelector,
663) -> Result<ResolvedBrowser> {
664    use crate::cli::env_resolver::{BrowserSelector, DefaultResolver, Resolver};
665    let startable_kind = crate::cli::mcp::startable_kind_from_selector(&selector);
666    let resolved = match selector {
667        BrowserSelector::Url(u) => match u.scheme() {
668            "ws" | "wss" => Ok(ResolvedBrowser {
669                engine: if u.path().contains("/session") {
670                    crate::detect::Engine::Bidi
671                } else {
672                    crate::detect::Engine::Cdp
673                },
674                endpoint: u.to_string(),
675                source: crate::cli::env_resolver::Source::External,
676            }),
677            "http" | "https" => {
678                let base = u.as_str().trim_end_matches('/').to_string();
679                let ws = DefaultResolver.fetch_version(&base).await?;
680                let ws_url = url::Url::parse(&ws)?;
681                Ok(ResolvedBrowser {
682                    engine: if ws_url.path().contains("/session") {
683                        crate::detect::Engine::Bidi
684                    } else {
685                        crate::detect::Engine::Cdp
686                    },
687                    endpoint: ws,
688                    source: crate::cli::env_resolver::Source::External,
689                })
690            }
691            other => anyhow::bail!("unsupported URL scheme: {other}"),
692        },
693        other => {
694            tokio::task::spawn_blocking(move || {
695                let reg = crate::registry::Registry::open()?;
696                // Non-URL branches of `resolve_with` are pure SQL with no
697                // awaits — the future polls to completion in one step.
698                // We need to drive a tiny async, but `block_on` is fine
699                // here on a blocking thread.
700                let rt = tokio::runtime::Builder::new_current_thread().build()?;
701                rt.block_on(crate::cli::env_resolver::resolve_with(
702                    other,
703                    &reg,
704                    &DefaultResolver,
705                ))
706            })
707            .await?
708        }
709    };
710    match resolved {
711        Ok(browser) => Ok(browser),
712        Err(resolve_err) => {
713            if let Some(kind) = startable_kind {
714                crate::cli::mcp::start_and_resolve(Some(kind.as_str().to_string()), false, 30)
715                    .await
716                    .with_context(|| {
717                        format!(
718                            "browser selector failed to resolve ({resolve_err:#}); also failed to start {}",
719                            kind.as_str()
720                        )
721                    })
722            } else {
723                Err(resolve_err)
724            }
725        }
726    }
727}
728
729/// Resolve a `target` URL-regex arg to a live `target_id` on `backend`,
730/// using the same probe-and-iterate semantics as `pick_cdp_page` /
731/// `pick_bidi_context` in `session::attach`. Walks `live_targets()` and
732/// returns the first matching responsive target; if all matches are
733/// unresponsive, returns `SessionError::TabHung`. Errors `anyhow` if
734/// the regex matches nothing.
735async fn resolve_target_by_regex(backend: &TabBackend, regex: &str) -> Result<String> {
736    use crate::errors::SessionError;
737    use regex::Regex;
738    use std::time::Duration;
739    const PROBE: Duration = Duration::from_millis(500);
740
741    let re = Regex::new(regex)?;
742    let targets = backend.live_targets().await?;
743    let matches: Vec<_> = targets.iter().filter(|t| re.is_match(&t.url)).collect();
744    if matches.is_empty() {
745        return Err(anyhow::anyhow!("no target matched URL regex `{regex}`"));
746    }
747    let mut last_id: Option<String> = None;
748    let mut last_url: Option<String> = None;
749    for t in &matches {
750        last_id = Some(t.id.clone());
751        last_url = Some(t.url.clone());
752        let ok = matches!(
753            tokio::time::timeout(PROBE, backend.evaluate(&t.id, "1", false, PROBE)).await,
754            Ok(Ok(_))
755        );
756        if ok {
757            return Ok(t.id.clone());
758        }
759    }
760    Err(SessionError::TabHung {
761        target_id: last_id,
762        url: last_url,
763        timeout_ms: PROBE.as_millis() as u64,
764        hint: "all-matches-hung",
765    }
766    .into())
767}
768
769impl std::fmt::Debug for ServerState {
770    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771        f.debug_struct("ServerState").finish()
772    }
773}
774
775/// Handler signature: takes `(state, params)` and returns a tool result.
776pub type ToolHandler = std::sync::Arc<
777    dyn Fn(ServerState, Value) -> futures_util::future::BoxFuture<'static, Result<Value>>
778        + Send
779        + Sync,
780>;
781
782pub struct RegisteredTool {
783    pub name: String,
784    pub description: String,
785    pub input_schema: Value,
786    pub handler: ToolHandler,
787}
788
789#[derive(Clone, Default)]
790pub struct ToolRegistry {
791    inner: std::sync::Arc<std::sync::Mutex<Vec<RegisteredTool>>>,
792}
793
794impl ToolRegistry {
795    pub fn new() -> Self {
796        Self::default()
797    }
798
799    pub fn register(&self, t: RegisteredTool) {
800        self.inner.lock().unwrap().push(t);
801    }
802
803    pub fn list(&self) -> Vec<Value> {
804        self.inner
805            .lock()
806            .unwrap()
807            .iter()
808            .map(|t| {
809                json!({
810                    "name": t.name,
811                    "description": t.description,
812                    "inputSchema": t.input_schema,
813                })
814            })
815            .collect()
816    }
817
818    pub fn handler(&self, name: &str) -> Option<ToolHandler> {
819        self.inner
820            .lock()
821            .unwrap()
822            .iter()
823            .find(|t| t.name == name)
824            .map(|t| t.handler.clone())
825    }
826}
827
828/// Run the server using the real stdin/stdout.
829pub async fn run(state: ServerState, tools: ToolRegistry) -> Result<()> {
830    run_with_streams(state, tools, tokio::io::stdin(), tokio::io::stdout()).await
831}
832
833/// Run the server with injected I/O streams (used by tests).
834///
835/// Requests are dispatched concurrently: `tools/call` handlers are spawned
836/// on their own tasks so a slow op (30s BiDi lock, sidecar `npm install`,
837/// 30s CDP timeout) never blocks `ping`, `tools/list`, or other calls on
838/// the connection. The read loop keeps pulling lines while handlers run.
839///
840/// Stdout is owned by a single writer task fed over an mpsc channel, so
841/// all response frames are serialized to the wire one at a time even though
842/// they are produced concurrently — no interleaved partial writes. Per
843/// JSON-RPC, response ordering is correlated by `id`, so out-of-order
844/// completion is sound. Correctness of shared backend access is unchanged:
845/// it is still serialized by the `ServerState` locks.
846pub async fn run_with_streams<I, O>(
847    state: ServerState,
848    tools: ToolRegistry,
849    stdin: I,
850    mut stdout: O,
851) -> Result<()>
852where
853    I: tokio::io::AsyncRead + Unpin,
854    O: tokio::io::AsyncWrite + Unpin + Send + 'static,
855{
856    // Single writer task owns stdout; every response frame (already
857    // serialized to bytes incl. trailing newline) flows through here so
858    // concurrent handlers can never interleave their writes.
859    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
860    let writer = tokio::spawn(async move {
861        while let Some(frame) = rx.recv().await {
862            if stdout.write_all(&frame).await.is_err() {
863                break;
864            }
865            if stdout.flush().await.is_err() {
866                break;
867            }
868        }
869    });
870
871    let mut lines = BufReader::new(stdin).lines();
872    while let Some(line) = lines.next_line().await? {
873        if line.trim().is_empty() {
874            continue;
875        }
876        let req: Value = match serde_json::from_str(&line) {
877            Ok(v) => v,
878            Err(e) => {
879                let _ = tx.send(error_frame(
880                    Value::Null,
881                    -32700,
882                    &format!("parse error: {e}"),
883                ));
884                continue;
885            }
886        };
887        let id = req.get("id").cloned().unwrap_or(Value::Null);
888        let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
889        let params = req.get("params").cloned().unwrap_or(Value::Null);
890
891        // Notifications: no id, no response.
892        if id.is_null() && method == "notifications/initialized" {
893            continue;
894        }
895
896        // Dispatch. `initialize` / `ping` / `tools/list` are cheap synchronous
897        // frame builders; `tools/call` spawns its handler so the read loop
898        // stays responsive while a slow op runs. Each arm sends through the
899        // single writer task, preserving serialized stdout writes.
900        match method {
901            "initialize" => {
902                let _ = tx.send(handle_initialize(id));
903            }
904            "ping" => {
905                let _ = tx.send(handle_ping(id));
906            }
907            "tools/list" => {
908                let _ = tx.send(handle_tools_list(id, &tools));
909            }
910            "tools/call" => {
911                handle_tools_call(id, &params, &state, &tools, &tx);
912            }
913            _ => {
914                let _ = tx.send(error_frame(
915                    id,
916                    -32601,
917                    &format!("method not found: {method}"),
918                ));
919            }
920        }
921    }
922    // stdin closed: wait for in-flight tool calls (write guard), release the
923    // engine session so Firefox accepts the next `session.new`, then drop
924    // our sender so the writer drains and exits, and wait for it so all
925    // buffered responses reach the wire before returning.
926    {
927        let _exclusive = state.op_barrier.write().await;
928        let backend = state.backend.lock().await.take();
929        if let Some(b) = backend {
930            b.shutdown().await;
931        }
932    }
933    drop(tx);
934    let _ = writer.await;
935    Ok(())
936}
937
938/// Build the `initialize` response frame: advertise the protocol version,
939/// the (tools-only) capability set, and server identity.
940fn handle_initialize(id: Value) -> Vec<u8> {
941    result_frame(
942        id,
943        json!({
944            "protocolVersion": "2024-11-05",
945            "capabilities": {"tools": {}},
946            "serverInfo": {
947                "name": "browser-control",
948                "version": env!("CARGO_PKG_VERSION"),
949            },
950        }),
951    )
952}
953
954/// Build the `ping` response frame (an empty result object).
955fn handle_ping(id: Value) -> Vec<u8> {
956    result_frame(id, json!({}))
957}
958
959/// Build the `tools/list` response frame from the registry.
960fn handle_tools_list(id: Value, tools: &ToolRegistry) -> Vec<u8> {
961    result_frame(id, json!({"tools": tools.list()}))
962}
963
964/// Dispatch a `tools/call` request. The handler is spawned on its own task
965/// so the read loop stays responsive while a slow op (30s BiDi lock,
966/// sidecar `npm install`, 30s CDP timeout) runs; the completed frame is
967/// sent to the single writer task, preserving serialized stdout writes.
968///
969/// Concurrency guard: `browser_select` (which calls `switch_browser`)
970/// acquires a **write** guard on `state.op_barrier`, blocking until all
971/// concurrent tool calls finish. Every other tool acquires a **read**
972/// guard, ensuring they cannot overlap with the destructive browser
973/// switch.
974fn handle_tools_call(
975    id: Value,
976    params: &Value,
977    state: &ServerState,
978    tools: &ToolRegistry,
979    tx: &tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
980) {
981    let name = params
982        .get("name")
983        .and_then(|v| v.as_str())
984        .unwrap_or("")
985        .to_string();
986    let args = params.get("arguments").cloned().unwrap_or(Value::Null);
987    let handler = tools.handler(&name);
988    let state = state.clone();
989    let tx = tx.clone();
990    let barrier = state.op_barrier.clone();
991    let is_exclusive = name == "browser_select";
992    tokio::spawn(async move {
993        let frame = match handler {
994            // An unknown tool name is bad params, not a tool
995            // failure: keep it a genuine `-32602` protocol error.
996            None => error_frame(id, -32602, &format!("tool not found: {name}")),
997            Some(h) => {
998                if is_exclusive {
999                    // Wait for every in-flight non-exclusive tool call
1000                    // to finish, then hold the write guard for the
1001                    // entire handler so no new tool call can start
1002                    // until the switch is complete.
1003                    let _guard = barrier.write().await;
1004                    match h(state, args).await {
1005                        Ok(v) => result_frame(id, v),
1006                        Err(e) => tool_error_frame(id, &e),
1007                    }
1008                } else {
1009                    let _guard = barrier.read().await;
1010                    let mark = is_download_action(&name).then(|| state.downloads.begin_action());
1011                    let downloads = state.downloads.clone();
1012                    let outcome = h(state, args).await;
1013                    if mark.is_some() {
1014                        downloads.end_action();
1015                    }
1016                    match outcome {
1017                        Ok(v) => result_frame(id, annotate_downloads(v, mark, &downloads)),
1018                        Err(e) => {
1019                            // A download can start even when the action then
1020                            // fails (e.g. a navigation that became a download).
1021                            // Appended to the rendered message: typed errors
1022                            // render without their context chain.
1023                            let note = mark.and_then(|m| {
1024                                crate::session::downloads::action_note(&downloads.since(m))
1025                            });
1026                            match note {
1027                                Some(n) => tool_error_text_frame(
1028                                    id,
1029                                    format!("{}\n{n}", tool_error_message(&e)),
1030                                ),
1031                                None => tool_error_frame(id, &e),
1032                            }
1033                        }
1034                    }
1035                }
1036            }
1037        };
1038        let _ = tx.send(frame);
1039    });
1040}
1041
1042/// Tools that can start a download. Their results report downloads that
1043/// began during the call, and the download override is re-sent for them
1044/// without debounce.
1045fn is_download_action(name: &str) -> bool {
1046    matches!(
1047        name,
1048        "browser_click"
1049            | "browser_type"
1050            | "browser_press_key"
1051            | "browser_navigate"
1052            | "browser_eval"
1053            | "browser_drag"
1054            | "browser_hover"
1055            | "browser_tab_new"
1056            | "browser_select_element"
1057    )
1058}
1059
1060/// Append a "download started" note to an action tool's text result.
1061fn annotate_downloads(
1062    mut v: Value,
1063    mark: Option<u64>,
1064    downloads: &crate::session::downloads::DownloadHub,
1065) -> Value {
1066    let Some(mark) = mark else {
1067        return v;
1068    };
1069    let Some(note) = crate::session::downloads::action_note(&downloads.since(mark)) else {
1070        return v;
1071    };
1072    if let Some(content) = v.get_mut("content").and_then(Value::as_array_mut) {
1073        content.push(json!({ "type": "text", "text": note }));
1074    }
1075    v
1076}
1077
1078/// Serialize a JSON-RPC success response to a newline-terminated frame.
1079fn result_frame(id: Value, result: Value) -> Vec<u8> {
1080    let resp = json!({"jsonrpc": "2.0", "id": id, "result": result});
1081    let mut s = serde_json::to_vec(&resp).unwrap_or_else(|_| b"{}".to_vec());
1082    s.push(b'\n');
1083    s
1084}
1085
1086/// Serialize a *tool-execution* failure as a successful JSON-RPC result with
1087/// `isError: true`, per the MCP spec (tool failures are not protocol faults —
1088/// the agent reads the content and recovers). Typed errors are downcast so the
1089/// content message carries their structure (e.g. the recover-once hint, or the
1090/// BiDi lock holder PID) instead of an opaque flattened string.
1091fn tool_error_frame(id: Value, err: &anyhow::Error) -> Vec<u8> {
1092    tool_error_text_frame(id, tool_error_message(err))
1093}
1094
1095fn tool_error_text_frame(id: Value, message: String) -> Vec<u8> {
1096    result_frame(
1097        id,
1098        json!({
1099            "content": [{ "type": "text", "text": message }],
1100            "isError": true,
1101        }),
1102    )
1103}
1104
1105/// Build the human/agent-readable message for a failed tool call. Downcasts
1106/// the typed error variants the agent can act on so their machine-relevant
1107/// fields survive (rather than relying on `Display` alone), falling back to the
1108/// full `anyhow` chain otherwise.
1109fn tool_error_message(err: &anyhow::Error) -> String {
1110    use crate::errors::SessionError;
1111    use crate::registry::bidi_lock::BidiLockBusy;
1112
1113    if let Some(se) = err.downcast_ref::<SessionError>() {
1114        // SessionError's Display already encodes target/url/hint and the
1115        // EngineUnsupported recovery hint, so reuse it verbatim.
1116        return se.to_string();
1117    }
1118    if let Some(busy) = err.downcast_ref::<BidiLockBusy>() {
1119        return busy.to_string();
1120    }
1121    // Unknown failure: surface the whole context chain ("{:#}") so causes
1122    // aren't lost.
1123    format!("{err:#}")
1124}
1125
1126/// Serialize a JSON-RPC error response to a newline-terminated frame.
1127fn error_frame(id: Value, code: i64, message: &str) -> Vec<u8> {
1128    let resp = json!({
1129        "jsonrpc": "2.0",
1130        "id": id,
1131        "error": {"code": code, "message": message},
1132    });
1133    let mut s = serde_json::to_vec(&resp).unwrap_or_else(|_| b"{}".to_vec());
1134    s.push(b'\n');
1135    s
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141    use crate::cli::env_resolver::Source;
1142    use crate::detect::Engine;
1143    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1144
1145    fn dummy_resolved() -> ResolvedBrowser {
1146        ResolvedBrowser {
1147            endpoint: "ws://localhost:9999".into(),
1148            engine: Engine::Cdp,
1149            source: Source::External,
1150        }
1151    }
1152
1153    fn dummy_state() -> ServerState {
1154        ServerState::new(dummy_resolved())
1155    }
1156
1157    async fn send_recv(tools: ToolRegistry, requests: &[Value]) -> Vec<Value> {
1158        let (mut client_w, server_r) = tokio::io::duplex(8192);
1159        let (server_w, client_r) = tokio::io::duplex(8192);
1160        let state = dummy_state();
1161        let join = tokio::spawn(async move {
1162            let _ = run_with_streams(state, tools, server_r, server_w).await;
1163        });
1164
1165        for req in requests {
1166            let mut s = serde_json::to_vec(req).unwrap();
1167            s.push(b'\n');
1168            client_w.write_all(&s).await.unwrap();
1169        }
1170        // Closing the writer ends the server loop after it drains.
1171        drop(client_w);
1172
1173        let mut reader = BufReader::new(client_r);
1174        let mut responses = Vec::new();
1175        loop {
1176            let mut line = String::new();
1177            let n = reader.read_line(&mut line).await.unwrap();
1178            if n == 0 {
1179                break;
1180            }
1181            responses.push(serde_json::from_str(line.trim()).unwrap());
1182        }
1183        let _ = join.await;
1184        responses
1185    }
1186
1187    fn echo_tool() -> RegisteredTool {
1188        RegisteredTool {
1189            name: "echo".to_string(),
1190            description: "Echo arguments back".to_string(),
1191            input_schema: json!({"type": "object"}),
1192            handler: std::sync::Arc::new(|_state, args| {
1193                Box::pin(async move { Ok(json!({"echoed": args})) })
1194            }),
1195        }
1196    }
1197
1198    fn failing_tool() -> RegisteredTool {
1199        RegisteredTool {
1200            name: "boom".to_string(),
1201            description: "Always fails with a typed SessionError".to_string(),
1202            input_schema: json!({"type": "object"}),
1203            handler: std::sync::Arc::new(|_state, _args| {
1204                Box::pin(async move {
1205                    Err(anyhow::Error::new(crate::errors::SessionError::TabHung {
1206                        target_id: Some("T1".into()),
1207                        url: Some("https://example.test".into()),
1208                        timeout_ms: 20_000,
1209                        hint: "renderer wedged",
1210                    }))
1211                })
1212            }),
1213        }
1214    }
1215
1216    #[tokio::test]
1217    async fn initialize_round_trip() {
1218        let resp = send_recv(
1219            ToolRegistry::new(),
1220            &[json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}})],
1221        )
1222        .await;
1223        assert_eq!(resp.len(), 1);
1224        assert_eq!(resp[0]["id"], 1);
1225        assert_eq!(resp[0]["result"]["protocolVersion"], "2024-11-05");
1226        assert_eq!(resp[0]["result"]["serverInfo"]["name"], "browser-control");
1227    }
1228
1229    #[tokio::test]
1230    async fn tools_list_empty() {
1231        let resp = send_recv(
1232            ToolRegistry::new(),
1233            &[json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})],
1234        )
1235        .await;
1236        assert_eq!(resp[0]["result"]["tools"], json!([]));
1237    }
1238
1239    #[tokio::test]
1240    async fn tools_list_after_register() {
1241        let tools = ToolRegistry::new();
1242        tools.register(echo_tool());
1243        let resp = send_recv(
1244            tools,
1245            &[json!({"jsonrpc":"2.0","id":3,"method":"tools/list"})],
1246        )
1247        .await;
1248        let list = resp[0]["result"]["tools"].as_array().unwrap();
1249        assert_eq!(list.len(), 1);
1250        assert_eq!(list[0]["name"], "echo");
1251    }
1252
1253    #[tokio::test]
1254    async fn tools_call_unknown_errors() {
1255        let resp = send_recv(
1256            ToolRegistry::new(),
1257            &[json!({
1258                "jsonrpc":"2.0","id":4,"method":"tools/call",
1259                "params":{"name":"nope","arguments":{}}
1260            })],
1261        )
1262        .await;
1263        // Unknown tool name is bad params — a genuine protocol fault.
1264        assert_eq!(resp[0]["error"]["code"], -32602);
1265        assert!(resp[0]["error"]["message"]
1266            .as_str()
1267            .unwrap()
1268            .contains("nope"));
1269    }
1270
1271    #[tokio::test]
1272    async fn tool_failure_returns_iserror_result_not_protocol_error() {
1273        let tools = ToolRegistry::new();
1274        tools.register(failing_tool());
1275        let resp = send_recv(
1276            tools,
1277            &[json!({
1278                "jsonrpc":"2.0","id":7,"method":"tools/call",
1279                "params":{"name":"boom","arguments":{}}
1280            })],
1281        )
1282        .await;
1283        // Spec-compliant: a tool that executes and fails is a *successful*
1284        // JSON-RPC result carrying `isError: true`, not a `-32xxx` fault.
1285        assert!(resp[0]["error"].is_null());
1286        assert_eq!(resp[0]["result"]["isError"], true);
1287        let text = resp[0]["result"]["content"][0]["text"].as_str().unwrap();
1288        // Typed SessionError structure survives (target id + hint).
1289        assert!(text.contains("tab hung"), "got: {text}");
1290        assert!(text.contains("renderer wedged"), "got: {text}");
1291        assert!(text.contains("T1"), "got: {text}");
1292    }
1293
1294    #[tokio::test]
1295    async fn tools_call_registered_returns_result() {
1296        let tools = ToolRegistry::new();
1297        tools.register(echo_tool());
1298        let resp = send_recv(
1299            tools,
1300            &[json!({
1301                "jsonrpc":"2.0","id":5,"method":"tools/call",
1302                "params":{"name":"echo","arguments":{"hello":"world"}}
1303            })],
1304        )
1305        .await;
1306        assert_eq!(resp[0]["result"]["echoed"], json!({"hello":"world"}));
1307    }
1308
1309    #[tokio::test]
1310    async fn unknown_method_returns_minus_32601() {
1311        let resp = send_recv(
1312            ToolRegistry::new(),
1313            &[json!({"jsonrpc":"2.0","id":6,"method":"bogus"})],
1314        )
1315        .await;
1316        assert_eq!(resp[0]["error"]["code"], -32601);
1317    }
1318
1319    #[tokio::test]
1320    async fn ping_returns_empty_object() {
1321        let resp = send_recv(
1322            ToolRegistry::new(),
1323            &[json!({"jsonrpc":"2.0","id":7,"method":"ping"})],
1324        )
1325        .await;
1326        assert_eq!(resp[0]["result"], json!({}));
1327    }
1328
1329    // -- resolve_target_for_args + concurrent ensure_backend ----------------
1330    //
1331    // These drive the real `ServerState` against an in-process mock CDP
1332    // WebSocket server (no browser) and a temp-dir registry (via
1333    // `BROWSER_CONTROL_DATA_DIR`). The mock counts accepted connections so we
1334    // can assert `ensure_backend`'s double-checked locking opens exactly one
1335    // backend under concurrency.
1336
1337    use futures_util::{SinkExt, StreamExt};
1338    use std::sync::atomic::{AtomicUsize, Ordering};
1339    use tokio_tungstenite::tungstenite::Message;
1340
1341    /// A CDP mock that reports a fixed set of live targets via
1342    /// `Target.getTargets` and counts how many WebSocket connections it
1343    /// accepts. Returns `(ws_url, connections_accepted, stop_tx)`.
1344    async fn spawn_counting_cdp_mock(
1345        live: Vec<String>,
1346    ) -> (String, Arc<AtomicUsize>, tokio::sync::oneshot::Sender<()>) {
1347        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1348        let addr = listener.local_addr().unwrap();
1349        let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
1350        let conns = Arc::new(AtomicUsize::new(0));
1351        let conns_srv = conns.clone();
1352        tokio::spawn(async move {
1353            loop {
1354                let accept = tokio::select! {
1355                    _ = &mut stop_rx => break,
1356                    a = listener.accept() => a,
1357                };
1358                let (stream, _) = match accept {
1359                    Ok(s) => s,
1360                    Err(_) => break,
1361                };
1362                conns_srv.fetch_add(1, Ordering::SeqCst);
1363                let live = live.clone();
1364                tokio::spawn(async move {
1365                    let mut ws = match tokio_tungstenite::accept_async(stream).await {
1366                        Ok(w) => w,
1367                        Err(_) => return,
1368                    };
1369                    while let Some(Ok(msg)) = ws.next().await {
1370                        if let Message::Text(t) = msg {
1371                            let req: Value = match serde_json::from_str(&t) {
1372                                Ok(v) => v,
1373                                Err(_) => continue,
1374                            };
1375                            let id = req["id"].as_u64().unwrap_or(0);
1376                            let method = req["method"].as_str().unwrap_or("");
1377                            let result = match method {
1378                                "Target.getTargets" => {
1379                                    let infos: Vec<Value> = live
1380                                        .iter()
1381                                        .map(|tid| {
1382                                            json!({"targetId": tid, "type": "page", "url": ""})
1383                                        })
1384                                        .collect();
1385                                    json!({"targetInfos": infos})
1386                                }
1387                                "Target.attachToTarget" => json!({"sessionId": "S1"}),
1388                                "Runtime.evaluate" => json!({"result": {"value": 1}}),
1389                                _ => json!({}),
1390                            };
1391                            let resp = json!({"id": id, "result": result});
1392                            if ws.send(Message::Text(resp.to_string())).await.is_err() {
1393                                break;
1394                            }
1395                        }
1396                    }
1397                });
1398            }
1399        });
1400        (format!("ws://{addr}"), conns, stop_tx)
1401    }
1402
1403    /// Build a `ServerState` for a *registered* browser (named-tab routing
1404    /// requires a stable identity) pointing at `endpoint`.
1405    fn registered_state(name: &str, endpoint: &str) -> ServerState {
1406        ServerState::new(ResolvedBrowser {
1407            endpoint: endpoint.to_string(),
1408            engine: Engine::Cdp,
1409            source: Source::Registered { name: name.into() },
1410        })
1411    }
1412
1413    fn register_browser_row(name: &str, endpoint: &str) {
1414        let port: u16 = endpoint
1415            .strip_prefix("ws://127.0.0.1:")
1416            .and_then(|s| s.split('/').next())
1417            .and_then(|s| s.parse().ok())
1418            .expect("parse mock port");
1419        let reg = crate::registry::Registry::open().unwrap();
1420        reg.insert(&crate::registry::BrowserRow {
1421            name: name.to_string(),
1422            kind: crate::detect::Kind::Chrome,
1423            engine: Engine::Cdp,
1424            pid: std::process::id(),
1425            endpoint: endpoint.to_string(),
1426            port,
1427            profile_dir: std::path::PathBuf::from("/tmp/profiles/bx"),
1428            executable: std::path::PathBuf::from("/usr/bin/example"),
1429            headless: false,
1430            started_at: "2024-01-01T00:00:00Z".into(),
1431        })
1432        .unwrap();
1433    }
1434
1435    // Holds the synchronous ENV_LOCK across awaits on purpose: it serializes
1436    // the whole env-mutating test against the rest of the suite.
1437    #[allow(clippy::await_holding_lock)]
1438    #[tokio::test]
1439    async fn resolve_named_tab_live_resolves_and_touches() {
1440        let _g = crate::test_support::ENV_LOCK
1441            .lock()
1442            .unwrap_or_else(|e| e.into_inner());
1443        let tmp = tempfile::TempDir::new().unwrap();
1444        std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1445
1446        // Register a named tab whose target is live in the mock.
1447        {
1448            let reg = crate::registry::Registry::open().unwrap();
1449            reg.tab_upsert("bx", "work", "T1", "about:blank", true)
1450                .unwrap();
1451        }
1452
1453        let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1454        register_browser_row("bx", &url);
1455        let state = registered_state("bx", &url);
1456
1457        let target_id = match state.resolve_target_for_args(&json!({"tab": "work"})).await {
1458            Ok((_backend, tid)) => tid,
1459            Err(e) => panic!("named tab should resolve: {e:#}"),
1460        };
1461        assert_eq!(target_id, "T1");
1462
1463        std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1464    }
1465
1466    // See note above: ENV_LOCK is intentionally held across awaits.
1467    #[allow(clippy::await_holding_lock)]
1468    #[tokio::test]
1469    async fn resolve_named_tab_stale_sweeps_and_errors() {
1470        let _g = crate::test_support::ENV_LOCK
1471            .lock()
1472            .unwrap_or_else(|e| e.into_inner());
1473        let tmp = tempfile::TempDir::new().unwrap();
1474        std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1475
1476        // Register a named tab whose target is NOT among the mock's live
1477        // targets — the resolve must sweep the stale row and return
1478        // TabNotFound.
1479        {
1480            let reg = crate::registry::Registry::open().unwrap();
1481            reg.tab_upsert("bx", "gone", "T_DEAD", "about:blank", true)
1482                .unwrap();
1483        }
1484
1485        let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1486        register_browser_row("bx", &url);
1487        let state = registered_state("bx", &url);
1488
1489        let err = match state.resolve_target_for_args(&json!({"tab": "gone"})).await {
1490            Ok(_) => panic!("stale named tab must error"),
1491            Err(e) => e,
1492        };
1493        let typed = err
1494            .downcast_ref::<crate::errors::SessionError>()
1495            .expect("typed SessionError");
1496        assert!(
1497            matches!(typed, crate::errors::SessionError::TabNotFound { .. }),
1498            "expected TabNotFound, got {typed:?}"
1499        );
1500
1501        // The stale row must have been swept.
1502        let reg = crate::registry::Registry::open().unwrap();
1503        assert!(
1504            reg.tab_get("bx", "gone").unwrap().is_none(),
1505            "row not swept"
1506        );
1507
1508        std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1509    }
1510
1511    // See note above: ENV_LOCK is intentionally held across awaits.
1512    #[allow(clippy::await_holding_lock)]
1513    #[tokio::test]
1514    async fn resolve_named_tab_missing_row_errors() {
1515        let _g = crate::test_support::ENV_LOCK
1516            .lock()
1517            .unwrap_or_else(|e| e.into_inner());
1518        let tmp = tempfile::TempDir::new().unwrap();
1519        std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1520
1521        let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1522        register_browser_row("bx", &url);
1523        let state = registered_state("bx", &url);
1524
1525        let err = match state.resolve_target_for_args(&json!({"tab": "nope"})).await {
1526            Ok(_) => panic!("unknown named tab must error"),
1527            Err(e) => e,
1528        };
1529        let typed = err
1530            .downcast_ref::<crate::errors::SessionError>()
1531            .expect("typed SessionError");
1532        assert!(
1533            matches!(typed, crate::errors::SessionError::TabNotFound { .. }),
1534            "expected TabNotFound, got {typed:?}"
1535        );
1536
1537        std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1538    }
1539
1540    #[tokio::test]
1541    async fn resolve_tab_and_target_mutually_exclusive() {
1542        // No registry / backend needed: the guard fires first.
1543        let state = dummy_state();
1544        let err = match state
1545            .resolve_target_for_args(&json!({"tab": "a", "target": "b"}))
1546            .await
1547        {
1548            Ok(_) => panic!("tab+target must error"),
1549            Err(e) => e,
1550        };
1551        assert!(
1552            err.to_string().contains("mutually exclusive"),
1553            "got: {err:#}"
1554        );
1555    }
1556
1557    #[tokio::test]
1558    async fn concurrent_ensure_backend_opens_one_backend() {
1559        // Fire many concurrent `ensure_backend` calls against a state whose
1560        // double-checked lock must open exactly one backend (one WS
1561        // connection to the mock). Guards future refactors of the lock.
1562        let (url, conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1563        let state = ServerState::new(ResolvedBrowser {
1564            endpoint: url,
1565            engine: Engine::Cdp,
1566            source: Source::External,
1567        });
1568
1569        let mut handles = Vec::new();
1570        for _ in 0..8 {
1571            let s = state.clone();
1572            handles.push(tokio::spawn(async move { s.ensure_backend().await }));
1573        }
1574        for h in handles {
1575            h.await.unwrap().expect("ensure_backend should succeed");
1576        }
1577        assert_eq!(
1578            conns.load(Ordering::SeqCst),
1579            1,
1580            "expected exactly one backend (one WS connection) under concurrency"
1581        );
1582    }
1583
1584    #[allow(clippy::await_holding_lock)]
1585    #[tokio::test]
1586    async fn external_cdp_backend_does_not_open_registry() {
1587        let _g = crate::test_support::ENV_LOCK
1588            .lock()
1589            .unwrap_or_else(|e| e.into_inner());
1590        let stale_path = {
1591            let tmp = tempfile::TempDir::new().unwrap();
1592            tmp.path().to_path_buf()
1593        };
1594        std::env::set_var("BROWSER_CONTROL_DATA_DIR", &stale_path);
1595
1596        let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1597        let state = ServerState::new(ResolvedBrowser {
1598            endpoint: url,
1599            engine: Engine::Cdp,
1600            source: Source::External,
1601        });
1602
1603        let result = state.ensure_backend().await;
1604
1605        std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1606        result.expect("external CDP backend must not depend on registry env");
1607    }
1608
1609    #[tokio::test]
1610    async fn initialized_notification_is_silently_ignored() {
1611        // Send notification, then a real request; we should only see the
1612        // response to the real request.
1613        let resp = send_recv(
1614            ToolRegistry::new(),
1615            &[
1616                json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
1617                json!({"jsonrpc":"2.0","id":8,"method":"ping"}),
1618            ],
1619        )
1620        .await;
1621        assert_eq!(resp.len(), 1);
1622        assert_eq!(resp[0]["id"], 8);
1623    }
1624}