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::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    /// Operation barrier. Non-exclusive tool calls acquire a **read**
72    /// guard so they can run concurrently; `switch_browser` acquires a
73    /// **write** guard which waits for all in-flight tool operations to
74    /// finish, preventing the old backend / BiDi session from being
75    /// torn down while another tool is still using it.
76    ///
77    /// `browser_select` is the only tool that needs exclusive access
78    /// (via `switch_browser`); `handle_tools_call` skips the read
79    /// guard for it to avoid deadlocking with its own write guard.
80    pub op_barrier: Arc<RwLock<()>>,
81}
82
83/// Three-state cache: `Pending` until first tool call attempts acquire;
84/// `Acquired` holding the guard; `NotApplicable` for CDP / external
85/// endpoints where no lock is needed.
86#[derive(Default)]
87pub enum BidiLockState {
88    #[default]
89    Pending,
90    Acquired(crate::registry::BidiLockGuard),
91    NotApplicable,
92}
93
94impl ServerState {
95    pub fn new(browser: ResolvedBrowser) -> Self {
96        Self::with_sidecar_config(browser, crate::sidecar::SidecarConfig::default())
97    }
98
99    /// Construct a `ServerState` with a non-default sidecar config (e.g.
100    /// a custom Playwright version from `--playwright-version`).
101    pub fn with_sidecar_config(
102        browser: ResolvedBrowser,
103        sidecar_config: crate::sidecar::SidecarConfig,
104    ) -> Self {
105        Self {
106            browser: Arc::new(RwLock::new(browser)),
107            bidi: Arc::new(Mutex::new(None)),
108            bidi_lock: Arc::new(Mutex::new(BidiLockState::Pending)),
109            backend: Arc::new(Mutex::new(None)),
110            active_target_id: Arc::new(Mutex::new(None)),
111            origin_target_ids: Arc::new(Mutex::new(HashMap::new())),
112            sidecar: Arc::new(Mutex::new(None)),
113            sidecar_config,
114            op_barrier: Arc::new(RwLock::new(())),
115        }
116    }
117
118    /// Lazy-spawn the Playwright sidecar against the current browser.
119    /// Errors with `EngineUnsupported` when the active browser is BiDi
120    /// (Playwright can't drive a user-launched Firefox over BiDi/CDP).
121    ///
122    /// Idempotent: subsequent calls return the cached handle. The handle
123    /// is dropped (and the child killed) when `switch_browser` clears it.
124    pub async fn ensure_sidecar(&self, tool_name: &str) -> Result<crate::sidecar::Sidecar> {
125        let resolved = self.browser_snapshot().await;
126        if resolved.engine != crate::detect::Engine::Cdp {
127            return Err(crate::errors::SessionError::EngineUnsupported {
128                tool: tool_name.to_string(),
129                required_engine: "Chromium (CDP)".into(),
130                current_engine: format!("{:?}", resolved.engine),
131                hint: "use engine-agnostic tools such as browser_get_html, browser_fetch, browser_take_screenshot, or switch to a Chromium browser via browser_select",
132            }
133            .into());
134        }
135        let mut guard = self.sidecar.lock().await;
136        if let Some(sc) = guard.as_ref() {
137            return Ok(sc.clone());
138        }
139        let sc = crate::sidecar::Sidecar::start(self.sidecar_config.clone()).await?;
140        sc.connect(&resolved.endpoint).await?;
141        *guard = Some(sc.clone());
142        Ok(sc)
143    }
144
145    /// Snapshot the current resolved browser (cheap clone of a small struct).
146    pub async fn browser_snapshot(&self) -> ResolvedBrowser {
147        self.browser.read().await.clone()
148    }
149
150    /// Ensure the BiDi single-session lock is held (if applicable).
151    /// Lazy + idempotent: called by each tool handler before opening a
152    /// BiDi session, returns immediately on second+ calls.
153    pub async fn ensure_bidi_lock(&self) -> Result<()> {
154        use crate::cli::env_resolver::Source;
155        use crate::cli::mcp::acquire_bidi_lock_if_needed;
156        use crate::detect::Engine;
157        use crate::registry::Registry;
158        let mut guard = self.bidi_lock.lock().await;
159        if matches!(*guard, BidiLockState::Pending) {
160            let resolved = self.browser_snapshot().await;
161            if resolved.engine != Engine::Bidi || matches!(resolved.source, Source::External) {
162                *guard = BidiLockState::NotApplicable;
163                return Ok(());
164            }
165            // `bidi_lock_acquire` polls with a blocking `std::thread::sleep`
166            // for up to 30s under contention. Run it on a blocking thread so
167            // we never park a tokio worker (mirrors `resolve_browser_send`).
168            // `Registry` is opened fresh inside and `resolved` is an owned
169            // clone, so both move into the closure.
170            let acquired = tokio::task::spawn_blocking(move || {
171                let registry = Registry::open()?;
172                acquire_bidi_lock_if_needed(&registry, &resolved)
173            })
174            .await??;
175            *guard = match acquired {
176                Some(lock) => BidiLockState::Acquired(lock),
177                None => BidiLockState::NotApplicable,
178            };
179        }
180        Ok(())
181    }
182
183    /// Lazy-open (or return cached) [`TabBackend`] for the server's
184    /// browser. Acquires the BiDi lock first if applicable. The backend
185    /// is cached for the server's lifetime so the BiDi `session.new`
186    /// handshake runs once and the CDP WebSocket is reused across calls.
187    pub async fn ensure_backend(&self) -> Result<TabBackend> {
188        self.ensure_bidi_lock().await?;
189        let mut guard = self.backend.lock().await;
190        if let Some(b) = guard.as_ref() {
191            return Ok(b.clone());
192        }
193        let resolved = self.browser_snapshot().await;
194        let b = crate::session::backend::open_backend(&resolved.endpoint, resolved.engine).await?;
195        *guard = Some(b.clone());
196        Ok(b)
197    }
198
199    /// Resolve the MCP server's "active tab" — backed by an in-memory
200    /// `active_target_id` pointer rather than a SQLite row.
201    ///
202    /// The returned `(backend, target_id)` is the routing pair stateful
203    /// MCP tools (`browser_navigate`, `browser_get_html`, …) use when no
204    /// explicit `tab` / `target` arg is given.
205    ///
206    /// Behaviour:
207    /// - **None** → create an `about:blank` and store it.
208    /// - **Set but dead** (no longer in `live_target_ids`) → recreate
209    ///   `about:blank` and re-point the pointer. This is the scratch-style
210    ///   implicit recovery for the **server-owned** active tab; explicit
211    ///   tabs created via `browser_tab_new` / `browser_tab_select` also
212    ///   travel through here once they become the active tab, but
213    ///   recovery there means the agent-named tab is gone — see
214    ///   `browser_tab_select`'s dead-tab handling for the explicit-select
215    ///   contract.
216    /// - **Set and alive** → return as-is.
217    pub async fn current_tab(&self) -> Result<(TabBackend, String)> {
218        let backend = self.ensure_backend().await?;
219        let mut pointer = self.active_target_id.lock().await;
220        if let Some(tid) = pointer.as_ref() {
221            let live = backend.live_target_ids().await?;
222            if live.contains(tid) {
223                return Ok((backend, tid.clone()));
224            }
225            // Dead — fall through to recreate.
226        }
227        let new_tid = backend.create_tab("about:blank").await?;
228        *pointer = Some(new_tid.clone());
229        Ok((backend, new_tid))
230    }
231
232    /// Resolve or create an MCP-owned tab for a fetch URL's origin.
233    ///
234    /// `TabBackend::resolve_or_create_for_origin` can only reuse targets
235    /// whose current browser URL still has the requested origin. During auth
236    /// expiry, an origin tab may redirect to an identity provider or login
237    /// route; if we only inspect current URLs, each retry can create another
238    /// tab. This cache records the target originally allocated for each
239    /// requested origin and reuses it while it is still live.
240    pub async fn resolve_or_create_for_origin(&self, url: &str) -> Result<(TabBackend, String)> {
241        let want =
242            url::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid fetch URL `{url}`: {e}"))?;
243        let origin_root = crate::session::attach::origin_root_url(&want);
244        let backend = self.ensure_backend().await?;
245        let mut origin_targets = self.origin_target_ids.lock().await;
246        let live_targets = backend.live_targets().await?;
247        let live_ids: std::collections::HashSet<&str> =
248            live_targets.iter().map(|t| t.id.as_str()).collect();
249
250        if let Some(cached) = origin_targets.get(&origin_root) {
251            if live_ids.contains(cached.as_str()) {
252                return Ok((backend, cached.clone()));
253            }
254            origin_targets.remove(&origin_root);
255        }
256
257        if let Some(existing) = live_targets.iter().find(|t| {
258            url::Url::parse(&t.url)
259                .map(|parsed| crate::session::attach::same_origin(&parsed, &want))
260                .unwrap_or(false)
261        }) {
262            origin_targets.insert(origin_root, existing.id.clone());
263            return Ok((backend, existing.id.clone()));
264        }
265
266        let new_tid = backend.create_tab(&origin_root).await?;
267        origin_targets.insert(origin_root, new_tid.clone());
268        Ok((backend, new_tid))
269    }
270
271    /// Route a stateful tool call to a backend + target id based on the
272    /// optional `tab` (named) and `target` (URL regex) args. `tab` and
273    /// `target` are mutually exclusive. Falls through to `current_tab()`
274    /// when neither is provided.
275    ///
276    /// For the named-tab path: the registered tab row is resolved (with
277    /// sweep-on-read for stale rows) and returned. Tools that want
278    /// recover-on-failure semantics should structure their op around the
279    /// returned `(backend, target_id)` — full `with_named_tab_recovery`
280    /// can't run from a `Send` MCP future because `Registry` is `!Send`.
281    ///
282    /// For the URL-regex path, probe-and-iterate via the live targets
283    /// snapshot. Surfaces `SessionError::TabHung` if every match is
284    /// unresponsive within a 500ms probe.
285    pub async fn resolve_target_for_args(&self, args: &Value) -> Result<(TabBackend, String)> {
286        let tab = args.get("tab").and_then(|v| v.as_str()).map(String::from);
287        let target = args
288            .get("target")
289            .and_then(|v| v.as_str())
290            .map(String::from);
291        match (tab, target) {
292            (Some(_), Some(_)) => Err(anyhow::anyhow!("`tab` and `target` are mutually exclusive")),
293            (Some(name), None) => {
294                let backend = self.ensure_backend().await?;
295                let browser_name = self.registered_browser_name().await?;
296                // Mimic `session::tabs::resolve_tab` here so we never hold
297                // a `!Send` `Registry` across `.await`: sync registry-read,
298                // async liveness probe, sync registry-mutate.
299                let bn = browser_name.clone();
300                let n = name.clone();
301                let row = sync_registry_op(move |reg| reg.tab_get(&bn, &n))
302                    .await?
303                    .ok_or_else(|| crate::errors::SessionError::TabNotFound {
304                        browser: browser_name.clone(),
305                        name: name.clone(),
306                    })?;
307                let live = backend.live_target_ids().await?;
308                if !live.contains(&row.target_id) {
309                    // Stale — sweep, then error.
310                    let bn = browser_name.clone();
311                    let n = name.clone();
312                    sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
313                    return Err(crate::errors::SessionError::TabNotFound {
314                        browser: browser_name,
315                        name,
316                    }
317                    .into());
318                }
319                let bn = browser_name.clone();
320                let n = name.clone();
321                sync_registry_op(move |reg| reg.tab_touch(&bn, &n)).await?;
322                Ok((backend, row.target_id))
323            }
324            (None, Some(regex)) => {
325                let backend = self.ensure_backend().await?;
326                let target_id = resolve_target_by_regex(&backend, &regex).await?;
327                Ok((backend, target_id))
328            }
329            (None, None) => self.current_tab().await,
330        }
331    }
332
333    /// The registered browser's name. Errors if the active browser is an
334    /// external URL endpoint (no stable identity for named tabs).
335    pub async fn registered_browser_name(&self) -> Result<String> {
336        use crate::cli::env_resolver::Source;
337        let resolved = self.browser_snapshot().await;
338        match resolved.source {
339            Source::Registered { name } => Ok(name),
340            Source::External => Err(anyhow::anyhow!(
341                "operation requires a registered browser; external URL endpoints \
342                 don't have a stable identity"
343            )),
344        }
345    }
346
347    /// Swap the active browser. Drops the cached backend and BiDi session,
348    /// releases the BiDi lock (if held), clears the active tab pointer,
349    /// then installs the new browser and re-acquires the BiDi lock if
350    /// the new one needs it. The next stateful tool call lazy-opens the
351    /// new backend.
352    ///
353    /// # Concurrency
354    /// The caller must hold a **write** guard on [`Self::op_barrier`] to
355    /// ensure no concurrent tool call is still using the old backend.
356    /// [`handle_tools_call`] acquires the write guard for `browser_select`
357    /// before invoking this method.
358    pub async fn switch_browser(&self, new_browser: ResolvedBrowser) -> Result<()> {
359        // Close the cached BiDi session if any (best-effort).
360        {
361            let mut bidi = self.bidi.lock().await;
362            if let Some(client) = bidi.take() {
363                let _ = client.session_end().await;
364            }
365        }
366        // Drop the cached backend so the next call rebuilds against
367        // the new browser.
368        {
369            let mut backend = self.backend.lock().await;
370            *backend = None;
371        }
372        // Release the BiDi lock guard (Drop releases it) and reset to Pending.
373        {
374            let mut lock = self.bidi_lock.lock().await;
375            *lock = BidiLockState::Pending;
376        }
377        // Clear the active tab pointer.
378        {
379            let mut pointer = self.active_target_id.lock().await;
380            *pointer = None;
381        }
382        // Clear origin-bound fetch target cache.
383        {
384            let mut origins = self.origin_target_ids.lock().await;
385            origins.clear();
386        }
387        // Dispose the Playwright sidecar — different browser means
388        // different CDP endpoint; the next sidecar tool spawns a fresh
389        // child connected to the new endpoint.
390        {
391            let mut sidecar = self.sidecar.lock().await;
392            if let Some(sc) = sidecar.take() {
393                let _ = sc.call("dispose", serde_json::json!({})).await;
394                // Drop releases the child via SidecarInner::drop.
395                drop(sc);
396            }
397        }
398        // Install the new browser.
399        {
400            let mut br = self.browser.write().await;
401            *br = new_browser;
402        }
403        // Eagerly re-acquire the BiDi lock if applicable, so any error
404        // surfaces here rather than at the next tool call.
405        self.ensure_bidi_lock().await?;
406        Ok(())
407    }
408}
409
410/// Helper that opens a fresh `Registry` and runs a closure against it,
411/// returning the result. Used by the MCP layer to keep `!Send`
412/// `rusqlite::Connection` references off of `.await`-crossing scopes.
413///
414/// The closure runs on a blocking thread via `tokio::task::spawn_blocking`
415/// (mirroring `resolve_browser_send`) so the synchronous `rusqlite` work
416/// and the blocking exclusive `flock` taken across schema migration in
417/// `Registry::open` never park a tokio worker. The `!Send` `Registry` is
418/// created and dropped entirely inside the closure, so it never crosses an
419/// `.await`.
420pub(crate) async fn sync_registry_op<T, F>(f: F) -> Result<T>
421where
422    F: FnOnce(&crate::registry::Registry) -> Result<T> + Send + 'static,
423    T: Send + 'static,
424{
425    tokio::task::spawn_blocking(move || {
426        let reg = crate::registry::Registry::open()?;
427        f(&reg)
428    })
429    .await?
430}
431
432/// Resolve a `BrowserSelector` to a `ResolvedBrowser` from a `Send`
433/// async context. The URL branch awaits an HTTP roundtrip (no registry
434/// needed); the registered/kind/path branches run synchronously via
435/// `tokio::task::spawn_blocking` so the `!Send` `Registry` never sits
436/// across `.await`.
437pub(crate) async fn resolve_browser_send(
438    selector: crate::cli::env_resolver::BrowserSelector,
439) -> Result<ResolvedBrowser> {
440    use crate::cli::env_resolver::{BrowserSelector, DefaultResolver, Resolver};
441    match selector {
442        BrowserSelector::Url(u) => match u.scheme() {
443            "ws" | "wss" => Ok(ResolvedBrowser {
444                engine: if u.path().contains("/session") {
445                    crate::detect::Engine::Bidi
446                } else {
447                    crate::detect::Engine::Cdp
448                },
449                endpoint: u.to_string(),
450                source: crate::cli::env_resolver::Source::External,
451            }),
452            "http" | "https" => {
453                let base = u.as_str().trim_end_matches('/').to_string();
454                let ws = DefaultResolver.fetch_version(&base).await?;
455                let ws_url = url::Url::parse(&ws)?;
456                Ok(ResolvedBrowser {
457                    engine: if ws_url.path().contains("/session") {
458                        crate::detect::Engine::Bidi
459                    } else {
460                        crate::detect::Engine::Cdp
461                    },
462                    endpoint: ws,
463                    source: crate::cli::env_resolver::Source::External,
464                })
465            }
466            other => anyhow::bail!("unsupported URL scheme: {other}"),
467        },
468        other => {
469            tokio::task::spawn_blocking(move || {
470                let reg = crate::registry::Registry::open()?;
471                // Non-URL branches of `resolve_with` are pure SQL with no
472                // awaits — the future polls to completion in one step.
473                // We need to drive a tiny async, but `block_on` is fine
474                // here on a blocking thread.
475                let rt = tokio::runtime::Builder::new_current_thread().build()?;
476                rt.block_on(crate::cli::env_resolver::resolve_with(
477                    other,
478                    &reg,
479                    &DefaultResolver,
480                ))
481            })
482            .await?
483        }
484    }
485}
486
487/// Resolve a `target` URL-regex arg to a live `target_id` on `backend`,
488/// using the same probe-and-iterate semantics as `pick_cdp_page` /
489/// `pick_bidi_context` in `session::attach`. Walks `live_targets()` and
490/// returns the first matching responsive target; if all matches are
491/// unresponsive, returns `SessionError::TabHung`. Errors `anyhow` if
492/// the regex matches nothing.
493async fn resolve_target_by_regex(backend: &TabBackend, regex: &str) -> Result<String> {
494    use crate::errors::SessionError;
495    use regex::Regex;
496    use std::time::Duration;
497    const PROBE: Duration = Duration::from_millis(500);
498
499    let re = Regex::new(regex)?;
500    let targets = backend.live_targets().await?;
501    let matches: Vec<_> = targets.iter().filter(|t| re.is_match(&t.url)).collect();
502    if matches.is_empty() {
503        return Err(anyhow::anyhow!("no target matched URL regex `{regex}`"));
504    }
505    let mut last_id: Option<String> = None;
506    let mut last_url: Option<String> = None;
507    for t in &matches {
508        last_id = Some(t.id.clone());
509        last_url = Some(t.url.clone());
510        let ok = matches!(
511            tokio::time::timeout(PROBE, backend.evaluate(&t.id, "1", false, PROBE)).await,
512            Ok(Ok(_))
513        );
514        if ok {
515            return Ok(t.id.clone());
516        }
517    }
518    Err(SessionError::TabHung {
519        target_id: last_id,
520        url: last_url,
521        timeout_ms: PROBE.as_millis() as u64,
522        hint: "all-matches-hung",
523    }
524    .into())
525}
526
527impl std::fmt::Debug for ServerState {
528    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529        f.debug_struct("ServerState").finish()
530    }
531}
532
533/// Handler signature: takes `(state, params)` and returns a tool result.
534pub type ToolHandler = std::sync::Arc<
535    dyn Fn(ServerState, Value) -> futures_util::future::BoxFuture<'static, Result<Value>>
536        + Send
537        + Sync,
538>;
539
540pub struct RegisteredTool {
541    pub name: String,
542    pub description: String,
543    pub input_schema: Value,
544    pub handler: ToolHandler,
545}
546
547#[derive(Clone, Default)]
548pub struct ToolRegistry {
549    inner: std::sync::Arc<std::sync::Mutex<Vec<RegisteredTool>>>,
550}
551
552impl ToolRegistry {
553    pub fn new() -> Self {
554        Self::default()
555    }
556
557    pub fn register(&self, t: RegisteredTool) {
558        self.inner.lock().unwrap().push(t);
559    }
560
561    pub fn list(&self) -> Vec<Value> {
562        self.inner
563            .lock()
564            .unwrap()
565            .iter()
566            .map(|t| {
567                json!({
568                    "name": t.name,
569                    "description": t.description,
570                    "inputSchema": t.input_schema,
571                })
572            })
573            .collect()
574    }
575
576    pub fn handler(&self, name: &str) -> Option<ToolHandler> {
577        self.inner
578            .lock()
579            .unwrap()
580            .iter()
581            .find(|t| t.name == name)
582            .map(|t| t.handler.clone())
583    }
584}
585
586/// Run the server using the real stdin/stdout.
587pub async fn run(state: ServerState, tools: ToolRegistry) -> Result<()> {
588    run_with_streams(state, tools, tokio::io::stdin(), tokio::io::stdout()).await
589}
590
591/// Run the server with injected I/O streams (used by tests).
592///
593/// Requests are dispatched concurrently: `tools/call` handlers are spawned
594/// on their own tasks so a slow op (30s BiDi lock, sidecar `npm install`,
595/// 30s CDP timeout) never blocks `ping`, `tools/list`, or other calls on
596/// the connection. The read loop keeps pulling lines while handlers run.
597///
598/// Stdout is owned by a single writer task fed over an mpsc channel, so
599/// all response frames are serialized to the wire one at a time even though
600/// they are produced concurrently — no interleaved partial writes. Per
601/// JSON-RPC, response ordering is correlated by `id`, so out-of-order
602/// completion is sound. Correctness of shared backend access is unchanged:
603/// it is still serialized by the `ServerState` locks.
604pub async fn run_with_streams<I, O>(
605    state: ServerState,
606    tools: ToolRegistry,
607    stdin: I,
608    mut stdout: O,
609) -> Result<()>
610where
611    I: tokio::io::AsyncRead + Unpin,
612    O: tokio::io::AsyncWrite + Unpin + Send + 'static,
613{
614    // Single writer task owns stdout; every response frame (already
615    // serialized to bytes incl. trailing newline) flows through here so
616    // concurrent handlers can never interleave their writes.
617    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
618    let writer = tokio::spawn(async move {
619        while let Some(frame) = rx.recv().await {
620            if stdout.write_all(&frame).await.is_err() {
621                break;
622            }
623            if stdout.flush().await.is_err() {
624                break;
625            }
626        }
627    });
628
629    let mut lines = BufReader::new(stdin).lines();
630    while let Some(line) = lines.next_line().await? {
631        if line.trim().is_empty() {
632            continue;
633        }
634        let req: Value = match serde_json::from_str(&line) {
635            Ok(v) => v,
636            Err(e) => {
637                let _ = tx.send(error_frame(
638                    Value::Null,
639                    -32700,
640                    &format!("parse error: {e}"),
641                ));
642                continue;
643            }
644        };
645        let id = req.get("id").cloned().unwrap_or(Value::Null);
646        let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
647        let params = req.get("params").cloned().unwrap_or(Value::Null);
648
649        // Notifications: no id, no response.
650        if id.is_null() && method == "notifications/initialized" {
651            continue;
652        }
653
654        // Dispatch. `initialize` / `ping` / `tools/list` are cheap synchronous
655        // frame builders; `tools/call` spawns its handler so the read loop
656        // stays responsive while a slow op runs. Each arm sends through the
657        // single writer task, preserving serialized stdout writes.
658        match method {
659            "initialize" => {
660                let _ = tx.send(handle_initialize(id));
661            }
662            "ping" => {
663                let _ = tx.send(handle_ping(id));
664            }
665            "tools/list" => {
666                let _ = tx.send(handle_tools_list(id, &tools));
667            }
668            "tools/call" => {
669                handle_tools_call(id, &params, &state, &tools, &tx);
670            }
671            _ => {
672                let _ = tx.send(error_frame(
673                    id,
674                    -32601,
675                    &format!("method not found: {method}"),
676                ));
677            }
678        }
679    }
680    // stdin closed: drop our sender so the writer drains and exits, then
681    // wait for it so all buffered responses reach the wire before returning.
682    drop(tx);
683    let _ = writer.await;
684    Ok(())
685}
686
687/// Build the `initialize` response frame: advertise the protocol version,
688/// the (tools-only) capability set, and server identity.
689fn handle_initialize(id: Value) -> Vec<u8> {
690    result_frame(
691        id,
692        json!({
693            "protocolVersion": "2024-11-05",
694            "capabilities": {"tools": {}},
695            "serverInfo": {
696                "name": "browser-control",
697                "version": env!("CARGO_PKG_VERSION"),
698            },
699        }),
700    )
701}
702
703/// Build the `ping` response frame (an empty result object).
704fn handle_ping(id: Value) -> Vec<u8> {
705    result_frame(id, json!({}))
706}
707
708/// Build the `tools/list` response frame from the registry.
709fn handle_tools_list(id: Value, tools: &ToolRegistry) -> Vec<u8> {
710    result_frame(id, json!({"tools": tools.list()}))
711}
712
713/// Dispatch a `tools/call` request. The handler is spawned on its own task
714/// so the read loop stays responsive while a slow op (30s BiDi lock,
715/// sidecar `npm install`, 30s CDP timeout) runs; the completed frame is
716/// sent to the single writer task, preserving serialized stdout writes.
717///
718/// Concurrency guard: `browser_select` (which calls `switch_browser`)
719/// acquires a **write** guard on `state.op_barrier`, blocking until all
720/// concurrent tool calls finish. Every other tool acquires a **read**
721/// guard, ensuring they cannot overlap with the destructive browser
722/// switch.
723fn handle_tools_call(
724    id: Value,
725    params: &Value,
726    state: &ServerState,
727    tools: &ToolRegistry,
728    tx: &tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
729) {
730    let name = params
731        .get("name")
732        .and_then(|v| v.as_str())
733        .unwrap_or("")
734        .to_string();
735    let args = params.get("arguments").cloned().unwrap_or(Value::Null);
736    let handler = tools.handler(&name);
737    let state = state.clone();
738    let tx = tx.clone();
739    let barrier = state.op_barrier.clone();
740    let is_exclusive = name == "browser_select";
741    tokio::spawn(async move {
742        let frame = match handler {
743            // An unknown tool name is bad params, not a tool
744            // failure: keep it a genuine `-32602` protocol error.
745            None => error_frame(id, -32602, &format!("tool not found: {name}")),
746            Some(h) => {
747                if is_exclusive {
748                    // Wait for every in-flight non-exclusive tool call
749                    // to finish, then hold the write guard for the
750                    // entire handler so no new tool call can start
751                    // until the switch is complete.
752                    let _guard = barrier.write().await;
753                    match h(state, args).await {
754                        Ok(v) => result_frame(id, v),
755                        Err(e) => tool_error_frame(id, &e),
756                    }
757                } else {
758                    let _guard = barrier.read().await;
759                    match h(state, args).await {
760                        Ok(v) => result_frame(id, v),
761                        Err(e) => tool_error_frame(id, &e),
762                    }
763                }
764            }
765        };
766        let _ = tx.send(frame);
767    });
768}
769
770/// Serialize a JSON-RPC success response to a newline-terminated frame.
771fn result_frame(id: Value, result: Value) -> Vec<u8> {
772    let resp = json!({"jsonrpc": "2.0", "id": id, "result": result});
773    let mut s = serde_json::to_vec(&resp).unwrap_or_else(|_| b"{}".to_vec());
774    s.push(b'\n');
775    s
776}
777
778/// Serialize a *tool-execution* failure as a successful JSON-RPC result with
779/// `isError: true`, per the MCP spec (tool failures are not protocol faults —
780/// the agent reads the content and recovers). Typed errors are downcast so the
781/// content message carries their structure (e.g. the recover-once hint, or the
782/// BiDi lock holder PID) instead of an opaque flattened string.
783fn tool_error_frame(id: Value, err: &anyhow::Error) -> Vec<u8> {
784    let message = tool_error_message(err);
785    result_frame(
786        id,
787        json!({
788            "content": [{ "type": "text", "text": message }],
789            "isError": true,
790        }),
791    )
792}
793
794/// Build the human/agent-readable message for a failed tool call. Downcasts
795/// the typed error variants the agent can act on so their machine-relevant
796/// fields survive (rather than relying on `Display` alone), falling back to the
797/// full `anyhow` chain otherwise.
798fn tool_error_message(err: &anyhow::Error) -> String {
799    use crate::errors::SessionError;
800    use crate::registry::bidi_lock::BidiLockBusy;
801
802    if let Some(se) = err.downcast_ref::<SessionError>() {
803        // SessionError's Display already encodes target/url/hint and the
804        // EngineUnsupported recovery hint, so reuse it verbatim.
805        return se.to_string();
806    }
807    if let Some(busy) = err.downcast_ref::<BidiLockBusy>() {
808        return busy.to_string();
809    }
810    // Unknown failure: surface the whole context chain ("{:#}") so causes
811    // aren't lost.
812    format!("{err:#}")
813}
814
815/// Serialize a JSON-RPC error response to a newline-terminated frame.
816fn error_frame(id: Value, code: i64, message: &str) -> Vec<u8> {
817    let resp = json!({
818        "jsonrpc": "2.0",
819        "id": id,
820        "error": {"code": code, "message": message},
821    });
822    let mut s = serde_json::to_vec(&resp).unwrap_or_else(|_| b"{}".to_vec());
823    s.push(b'\n');
824    s
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use crate::cli::env_resolver::Source;
831    use crate::detect::Engine;
832    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
833
834    fn dummy_resolved() -> ResolvedBrowser {
835        ResolvedBrowser {
836            endpoint: "ws://localhost:9999".into(),
837            engine: Engine::Cdp,
838            source: Source::External,
839        }
840    }
841
842    fn dummy_state() -> ServerState {
843        ServerState::new(dummy_resolved())
844    }
845
846    async fn send_recv(tools: ToolRegistry, requests: &[Value]) -> Vec<Value> {
847        let (mut client_w, server_r) = tokio::io::duplex(8192);
848        let (server_w, client_r) = tokio::io::duplex(8192);
849        let state = dummy_state();
850        let join = tokio::spawn(async move {
851            let _ = run_with_streams(state, tools, server_r, server_w).await;
852        });
853
854        for req in requests {
855            let mut s = serde_json::to_vec(req).unwrap();
856            s.push(b'\n');
857            client_w.write_all(&s).await.unwrap();
858        }
859        // Closing the writer ends the server loop after it drains.
860        drop(client_w);
861
862        let mut reader = BufReader::new(client_r);
863        let mut responses = Vec::new();
864        loop {
865            let mut line = String::new();
866            let n = reader.read_line(&mut line).await.unwrap();
867            if n == 0 {
868                break;
869            }
870            responses.push(serde_json::from_str(line.trim()).unwrap());
871        }
872        let _ = join.await;
873        responses
874    }
875
876    fn echo_tool() -> RegisteredTool {
877        RegisteredTool {
878            name: "echo".to_string(),
879            description: "Echo arguments back".to_string(),
880            input_schema: json!({"type": "object"}),
881            handler: std::sync::Arc::new(|_state, args| {
882                Box::pin(async move { Ok(json!({"echoed": args})) })
883            }),
884        }
885    }
886
887    fn failing_tool() -> RegisteredTool {
888        RegisteredTool {
889            name: "boom".to_string(),
890            description: "Always fails with a typed SessionError".to_string(),
891            input_schema: json!({"type": "object"}),
892            handler: std::sync::Arc::new(|_state, _args| {
893                Box::pin(async move {
894                    Err(anyhow::Error::new(crate::errors::SessionError::TabHung {
895                        target_id: Some("T1".into()),
896                        url: Some("https://example.test".into()),
897                        timeout_ms: 20_000,
898                        hint: "renderer wedged",
899                    }))
900                })
901            }),
902        }
903    }
904
905    #[tokio::test]
906    async fn initialize_round_trip() {
907        let resp = send_recv(
908            ToolRegistry::new(),
909            &[json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}})],
910        )
911        .await;
912        assert_eq!(resp.len(), 1);
913        assert_eq!(resp[0]["id"], 1);
914        assert_eq!(resp[0]["result"]["protocolVersion"], "2024-11-05");
915        assert_eq!(resp[0]["result"]["serverInfo"]["name"], "browser-control");
916    }
917
918    #[tokio::test]
919    async fn tools_list_empty() {
920        let resp = send_recv(
921            ToolRegistry::new(),
922            &[json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})],
923        )
924        .await;
925        assert_eq!(resp[0]["result"]["tools"], json!([]));
926    }
927
928    #[tokio::test]
929    async fn tools_list_after_register() {
930        let tools = ToolRegistry::new();
931        tools.register(echo_tool());
932        let resp = send_recv(
933            tools,
934            &[json!({"jsonrpc":"2.0","id":3,"method":"tools/list"})],
935        )
936        .await;
937        let list = resp[0]["result"]["tools"].as_array().unwrap();
938        assert_eq!(list.len(), 1);
939        assert_eq!(list[0]["name"], "echo");
940    }
941
942    #[tokio::test]
943    async fn tools_call_unknown_errors() {
944        let resp = send_recv(
945            ToolRegistry::new(),
946            &[json!({
947                "jsonrpc":"2.0","id":4,"method":"tools/call",
948                "params":{"name":"nope","arguments":{}}
949            })],
950        )
951        .await;
952        // Unknown tool name is bad params — a genuine protocol fault.
953        assert_eq!(resp[0]["error"]["code"], -32602);
954        assert!(resp[0]["error"]["message"]
955            .as_str()
956            .unwrap()
957            .contains("nope"));
958    }
959
960    #[tokio::test]
961    async fn tool_failure_returns_iserror_result_not_protocol_error() {
962        let tools = ToolRegistry::new();
963        tools.register(failing_tool());
964        let resp = send_recv(
965            tools,
966            &[json!({
967                "jsonrpc":"2.0","id":7,"method":"tools/call",
968                "params":{"name":"boom","arguments":{}}
969            })],
970        )
971        .await;
972        // Spec-compliant: a tool that executes and fails is a *successful*
973        // JSON-RPC result carrying `isError: true`, not a `-32xxx` fault.
974        assert!(resp[0]["error"].is_null());
975        assert_eq!(resp[0]["result"]["isError"], true);
976        let text = resp[0]["result"]["content"][0]["text"].as_str().unwrap();
977        // Typed SessionError structure survives (target id + hint).
978        assert!(text.contains("tab hung"), "got: {text}");
979        assert!(text.contains("renderer wedged"), "got: {text}");
980        assert!(text.contains("T1"), "got: {text}");
981    }
982
983    #[tokio::test]
984    async fn tools_call_registered_returns_result() {
985        let tools = ToolRegistry::new();
986        tools.register(echo_tool());
987        let resp = send_recv(
988            tools,
989            &[json!({
990                "jsonrpc":"2.0","id":5,"method":"tools/call",
991                "params":{"name":"echo","arguments":{"hello":"world"}}
992            })],
993        )
994        .await;
995        assert_eq!(resp[0]["result"]["echoed"], json!({"hello":"world"}));
996    }
997
998    #[tokio::test]
999    async fn unknown_method_returns_minus_32601() {
1000        let resp = send_recv(
1001            ToolRegistry::new(),
1002            &[json!({"jsonrpc":"2.0","id":6,"method":"bogus"})],
1003        )
1004        .await;
1005        assert_eq!(resp[0]["error"]["code"], -32601);
1006    }
1007
1008    #[tokio::test]
1009    async fn ping_returns_empty_object() {
1010        let resp = send_recv(
1011            ToolRegistry::new(),
1012            &[json!({"jsonrpc":"2.0","id":7,"method":"ping"})],
1013        )
1014        .await;
1015        assert_eq!(resp[0]["result"], json!({}));
1016    }
1017
1018    // -- resolve_target_for_args + concurrent ensure_backend ----------------
1019    //
1020    // These drive the real `ServerState` against an in-process mock CDP
1021    // WebSocket server (no browser) and a temp-dir registry (via
1022    // `BROWSER_CONTROL_DATA_DIR`). The mock counts accepted connections so we
1023    // can assert `ensure_backend`'s double-checked locking opens exactly one
1024    // backend under concurrency.
1025
1026    use futures_util::{SinkExt, StreamExt};
1027    use std::sync::atomic::{AtomicUsize, Ordering};
1028    use tokio_tungstenite::tungstenite::Message;
1029
1030    /// A CDP mock that reports a fixed set of live targets via
1031    /// `Target.getTargets` and counts how many WebSocket connections it
1032    /// accepts. Returns `(ws_url, connections_accepted, stop_tx)`.
1033    async fn spawn_counting_cdp_mock(
1034        live: Vec<String>,
1035    ) -> (String, Arc<AtomicUsize>, tokio::sync::oneshot::Sender<()>) {
1036        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1037        let addr = listener.local_addr().unwrap();
1038        let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
1039        let conns = Arc::new(AtomicUsize::new(0));
1040        let conns_srv = conns.clone();
1041        tokio::spawn(async move {
1042            loop {
1043                let accept = tokio::select! {
1044                    _ = &mut stop_rx => break,
1045                    a = listener.accept() => a,
1046                };
1047                let (stream, _) = match accept {
1048                    Ok(s) => s,
1049                    Err(_) => break,
1050                };
1051                conns_srv.fetch_add(1, Ordering::SeqCst);
1052                let live = live.clone();
1053                tokio::spawn(async move {
1054                    let mut ws = match tokio_tungstenite::accept_async(stream).await {
1055                        Ok(w) => w,
1056                        Err(_) => return,
1057                    };
1058                    while let Some(Ok(msg)) = ws.next().await {
1059                        if let Message::Text(t) = msg {
1060                            let req: Value = match serde_json::from_str(&t) {
1061                                Ok(v) => v,
1062                                Err(_) => continue,
1063                            };
1064                            let id = req["id"].as_u64().unwrap_or(0);
1065                            let method = req["method"].as_str().unwrap_or("");
1066                            let result = match method {
1067                                "Target.getTargets" => {
1068                                    let infos: Vec<Value> = live
1069                                        .iter()
1070                                        .map(|tid| {
1071                                            json!({"targetId": tid, "type": "page", "url": ""})
1072                                        })
1073                                        .collect();
1074                                    json!({"targetInfos": infos})
1075                                }
1076                                "Target.attachToTarget" => json!({"sessionId": "S1"}),
1077                                "Runtime.evaluate" => json!({"result": {"value": 1}}),
1078                                _ => json!({}),
1079                            };
1080                            let resp = json!({"id": id, "result": result});
1081                            if ws.send(Message::Text(resp.to_string())).await.is_err() {
1082                                break;
1083                            }
1084                        }
1085                    }
1086                });
1087            }
1088        });
1089        (format!("ws://{addr}"), conns, stop_tx)
1090    }
1091
1092    /// Build a `ServerState` for a *registered* browser (named-tab routing
1093    /// requires a stable identity) pointing at `endpoint`.
1094    fn registered_state(name: &str, endpoint: &str) -> ServerState {
1095        ServerState::new(ResolvedBrowser {
1096            endpoint: endpoint.to_string(),
1097            engine: Engine::Cdp,
1098            source: Source::Registered { name: name.into() },
1099        })
1100    }
1101
1102    // Holds the synchronous ENV_LOCK across awaits on purpose: it serializes
1103    // the whole env-mutating test against the rest of the suite.
1104    #[allow(clippy::await_holding_lock)]
1105    #[tokio::test]
1106    async fn resolve_named_tab_live_resolves_and_touches() {
1107        let _g = crate::test_support::ENV_LOCK
1108            .lock()
1109            .unwrap_or_else(|e| e.into_inner());
1110        let tmp = tempfile::TempDir::new().unwrap();
1111        std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1112
1113        // Register a named tab whose target is live in the mock.
1114        {
1115            let reg = crate::registry::Registry::open().unwrap();
1116            reg.tab_upsert("bx", "work", "T1", "about:blank", true)
1117                .unwrap();
1118        }
1119
1120        let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1121        let state = registered_state("bx", &url);
1122
1123        let target_id = match state.resolve_target_for_args(&json!({"tab": "work"})).await {
1124            Ok((_backend, tid)) => tid,
1125            Err(e) => panic!("named tab should resolve: {e:#}"),
1126        };
1127        assert_eq!(target_id, "T1");
1128
1129        std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1130    }
1131
1132    // See note above: ENV_LOCK is intentionally held across awaits.
1133    #[allow(clippy::await_holding_lock)]
1134    #[tokio::test]
1135    async fn resolve_named_tab_stale_sweeps_and_errors() {
1136        let _g = crate::test_support::ENV_LOCK
1137            .lock()
1138            .unwrap_or_else(|e| e.into_inner());
1139        let tmp = tempfile::TempDir::new().unwrap();
1140        std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1141
1142        // Register a named tab whose target is NOT among the mock's live
1143        // targets — the resolve must sweep the stale row and return
1144        // TabNotFound.
1145        {
1146            let reg = crate::registry::Registry::open().unwrap();
1147            reg.tab_upsert("bx", "gone", "T_DEAD", "about:blank", true)
1148                .unwrap();
1149        }
1150
1151        let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1152        let state = registered_state("bx", &url);
1153
1154        let err = match state.resolve_target_for_args(&json!({"tab": "gone"})).await {
1155            Ok(_) => panic!("stale named tab must error"),
1156            Err(e) => e,
1157        };
1158        let typed = err
1159            .downcast_ref::<crate::errors::SessionError>()
1160            .expect("typed SessionError");
1161        assert!(
1162            matches!(typed, crate::errors::SessionError::TabNotFound { .. }),
1163            "expected TabNotFound, got {typed:?}"
1164        );
1165
1166        // The stale row must have been swept.
1167        let reg = crate::registry::Registry::open().unwrap();
1168        assert!(
1169            reg.tab_get("bx", "gone").unwrap().is_none(),
1170            "row not swept"
1171        );
1172
1173        std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1174    }
1175
1176    // See note above: ENV_LOCK is intentionally held across awaits.
1177    #[allow(clippy::await_holding_lock)]
1178    #[tokio::test]
1179    async fn resolve_named_tab_missing_row_errors() {
1180        let _g = crate::test_support::ENV_LOCK
1181            .lock()
1182            .unwrap_or_else(|e| e.into_inner());
1183        let tmp = tempfile::TempDir::new().unwrap();
1184        std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1185
1186        let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1187        let state = registered_state("bx", &url);
1188
1189        let err = match state.resolve_target_for_args(&json!({"tab": "nope"})).await {
1190            Ok(_) => panic!("unknown named tab must error"),
1191            Err(e) => e,
1192        };
1193        let typed = err
1194            .downcast_ref::<crate::errors::SessionError>()
1195            .expect("typed SessionError");
1196        assert!(
1197            matches!(typed, crate::errors::SessionError::TabNotFound { .. }),
1198            "expected TabNotFound, got {typed:?}"
1199        );
1200
1201        std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1202    }
1203
1204    #[tokio::test]
1205    async fn resolve_tab_and_target_mutually_exclusive() {
1206        // No registry / backend needed: the guard fires first.
1207        let state = dummy_state();
1208        let err = match state
1209            .resolve_target_for_args(&json!({"tab": "a", "target": "b"}))
1210            .await
1211        {
1212            Ok(_) => panic!("tab+target must error"),
1213            Err(e) => e,
1214        };
1215        assert!(
1216            err.to_string().contains("mutually exclusive"),
1217            "got: {err:#}"
1218        );
1219    }
1220
1221    #[tokio::test]
1222    async fn concurrent_ensure_backend_opens_one_backend() {
1223        // Fire many concurrent `ensure_backend` calls against a state whose
1224        // double-checked lock must open exactly one backend (one WS
1225        // connection to the mock). Guards future refactors of the lock.
1226        let (url, conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1227        let state = ServerState::new(ResolvedBrowser {
1228            endpoint: url,
1229            engine: Engine::Cdp,
1230            source: Source::External,
1231        });
1232
1233        let mut handles = Vec::new();
1234        for _ in 0..8 {
1235            let s = state.clone();
1236            handles.push(tokio::spawn(async move { s.ensure_backend().await }));
1237        }
1238        for h in handles {
1239            h.await.unwrap().expect("ensure_backend should succeed");
1240        }
1241        assert_eq!(
1242            conns.load(Ordering::SeqCst),
1243            1,
1244            "expected exactly one backend (one WS connection) under concurrency"
1245        );
1246    }
1247
1248    #[allow(clippy::await_holding_lock)]
1249    #[tokio::test]
1250    async fn external_cdp_backend_does_not_open_registry() {
1251        let _g = crate::test_support::ENV_LOCK
1252            .lock()
1253            .unwrap_or_else(|e| e.into_inner());
1254        let stale_path = {
1255            let tmp = tempfile::TempDir::new().unwrap();
1256            tmp.path().to_path_buf()
1257        };
1258        std::env::set_var("BROWSER_CONTROL_DATA_DIR", &stale_path);
1259
1260        let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1261        let state = ServerState::new(ResolvedBrowser {
1262            endpoint: url,
1263            engine: Engine::Cdp,
1264            source: Source::External,
1265        });
1266
1267        let result = state.ensure_backend().await;
1268
1269        std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1270        result.expect("external CDP backend must not depend on registry env");
1271    }
1272
1273    #[tokio::test]
1274    async fn initialized_notification_is_silently_ignored() {
1275        // Send notification, then a real request; we should only see the
1276        // response to the real request.
1277        let resp = send_recv(
1278            ToolRegistry::new(),
1279            &[
1280                json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
1281                json!({"jsonrpc":"2.0","id":8,"method":"ping"}),
1282            ],
1283        )
1284        .await;
1285        assert_eq!(resp.len(), 1);
1286        assert_eq!(resp[0]["id"], 8);
1287    }
1288}