Skip to main content

browser_control/cli/
mcp.rs

1//! `browser-control mcp` subcommand entry point.
2
3use anyhow::{Context, Result};
4
5use crate::cli::env_resolver::{self, BrowserSelector, ResolvedBrowser, Source};
6use crate::detect::Kind;
7use crate::mcp::server::{run, ServerState, ToolRegistry};
8use crate::registry::{BrowserRow, Registry};
9
10/// Entry point for `browser-control mcp`.
11pub async fn run_cli(
12    browser_arg: Option<String>,
13    playwright_version: Option<String>,
14) -> Result<()> {
15    let resolved = resolve_browser(browser_arg).await?;
16    let sidecar_config = crate::sidecar::SidecarConfig {
17        version: playwright_version,
18    };
19    let state = ServerState::with_sidecar_config(resolved, sidecar_config);
20    let tools = ToolRegistry::new();
21    crate::mcp::tools::register_all(&tools);
22    run(state, tools).await
23}
24
25/// Resolution order: positional arg / `BROWSER_CONTROL` env (arg wins, env is
26/// the fallback — both are merged by clap into `browser_arg`) > persisted
27/// default (`browser-control set default ...`) > most recent live browser >
28/// start the default installed browser.
29///
30/// MCP must be recoverable by the agent using it. Unlike short-lived CLI
31/// commands, the server cannot exit before exposing tools such as
32/// `browser_start` / `browser_select`, so the no-explicit-selection path picks
33/// or starts a usable browser.
34pub async fn resolve_browser(browser_arg: Option<String>) -> Result<ResolvedBrowser> {
35    let registry = Registry::open()?;
36    if let Some(arg) = browser_arg.as_deref().filter(|s| !s.is_empty()) {
37        let sel = env_resolver::parse(arg)?;
38        return resolve_selector_or_start(sel, &registry).await;
39    }
40    if let Some(value) = crate::config::load()?.default {
41        match resolve_default_browser(&value, &registry).await {
42            Ok(resolved) => return Ok(resolved),
43            Err(default_err) => {
44                if let Some(row) = registry.most_recent_alive()? {
45                    return Ok(resolved_from_row(row));
46                }
47                if let Ok(sel) = env_resolver::parse(&value) {
48                    if let Some(kind) = startable_kind_from_selector(&sel) {
49                        return start_and_resolve(Some(kind.as_str().to_string()), false, 30)
50                            .await
51                            .with_context(|| {
52                                format!(
53                                    "default browser `{value}` failed to resolve ({default_err:#}); also failed to start {}",
54                                    kind.as_str()
55                                )
56                            });
57                    }
58                }
59                return start_and_resolve(None, false, 30).await.with_context(|| {
60                    format!(
61                        "default browser `{value}` failed to resolve ({default_err:#}); also failed to start a browser"
62                    )
63                });
64            }
65        }
66    }
67    if let Some(row) = registry.most_recent_alive()? {
68        return Ok(resolved_from_row(row));
69    }
70    start_and_resolve(None, false, 30).await
71}
72
73async fn resolve_default_browser(value: &str, registry: &Registry) -> Result<ResolvedBrowser> {
74    let sel = env_resolver::parse(value)?;
75    if let BrowserSelector::Name(name) = &sel {
76        if let Some(kind) = stale_default_kind(name, registry)? {
77            rewrite_default_if_unchanged(value, kind)?;
78            return env_resolver::resolve(BrowserSelector::Kind(kind), registry)
79                .await
80                .with_context(|| {
81                    format!(
82                        "default browser `{name}` is stale; rewrote default to `{}` but failed to resolve a live {} browser",
83                        kind.as_str(),
84                        kind.as_str()
85                    )
86                });
87        }
88    }
89    env_resolver::resolve(sel, registry).await
90}
91
92async fn resolve_selector_or_start(
93    selector: BrowserSelector,
94    registry: &Registry,
95) -> Result<ResolvedBrowser> {
96    match env_resolver::resolve(selector.clone(), registry).await {
97        Ok(resolved) => Ok(resolved),
98        Err(resolve_err) => {
99            if let Some(kind) = startable_kind_from_selector(&selector) {
100                return start_and_resolve(Some(kind.as_str().to_string()), false, 30)
101                    .await
102                    .with_context(|| {
103                        format!(
104                            "browser selector failed to resolve ({resolve_err:#}); also failed to start {}",
105                            kind.as_str()
106                        )
107                    });
108            }
109            Err(resolve_err)
110        }
111    }
112}
113
114pub(crate) fn startable_kind_from_selector(selector: &BrowserSelector) -> Option<Kind> {
115    match selector {
116        BrowserSelector::Kind(kind) => Some(*kind),
117        BrowserSelector::Name(name) => kind_from_generated_name(name),
118        BrowserSelector::Url(_) | BrowserSelector::ExecutablePath(_) => None,
119    }
120}
121
122pub async fn start_and_resolve(
123    browser: Option<String>,
124    headless: bool,
125    wait_timeout: u64,
126) -> Result<ResolvedBrowser> {
127    let started = crate::cli::start::ensure_started(browser, headless, false, wait_timeout).await?;
128    Ok(ResolvedBrowser {
129        endpoint: started.endpoint,
130        engine: started.engine,
131        source: Source::Registered { name: started.name },
132    })
133}
134
135fn resolved_from_row(row: BrowserRow) -> ResolvedBrowser {
136    ResolvedBrowser {
137        endpoint: row.endpoint,
138        engine: row.engine,
139        source: Source::Registered { name: row.name },
140    }
141}
142
143fn stale_default_kind(name: &str, registry: &Registry) -> Result<Option<Kind>> {
144    if let Some(row) = registry
145        .get_by_name(name)
146        .with_context(|| format!("looking up default browser {name}"))?
147    {
148        return match crate::registry::liveness(&row) {
149            crate::registry::BrowserLiveness::DeadPid => {
150                registry
151                    .delete(&row.name)
152                    .with_context(|| format!("pruning stale default browser {}", row.name))?;
153                Ok(Some(row.kind))
154            }
155            crate::registry::BrowserLiveness::Alive
156            | crate::registry::BrowserLiveness::EndpointUnreachable => Ok(None),
157        };
158    }
159
160    Ok(kind_from_generated_name(name))
161}
162
163fn kind_from_generated_name(name: &str) -> Option<Kind> {
164    let (prefix, _) = name.split_once('-')?;
165    Kind::parse(prefix)
166}
167
168fn rewrite_default_if_unchanged(previous: &str, kind: Kind) -> Result<()> {
169    let mut cfg = crate::config::load()?;
170    if cfg.default.as_deref() == Some(previous) {
171        cfg.default = Some(kind.as_str().to_string());
172        crate::config::save(&cfg)
173            .with_context(|| format!("rewriting stale default browser `{previous}`"))?;
174    }
175    Ok(())
176}
177
178/// Acquire the Firefox BiDi single-session lock if `resolved` is a
179/// registered browser on the BiDi engine. Returns `None` for external
180/// URL endpoints (the lock is per-registered-name) and for CDP engines.
181/// Wait bound: 30 s; on timeout, returns the typed `BidiLockBusy` error.
182pub fn acquire_bidi_lock_if_needed(
183    registry: &crate::registry::Registry,
184    resolved: &ResolvedBrowser,
185) -> Result<Option<crate::registry::BidiLockGuard>> {
186    use crate::detect::Engine;
187    if resolved.engine != Engine::Bidi {
188        return Ok(None);
189    }
190    let name = match &resolved.source {
191        crate::cli::env_resolver::Source::Registered { name } => name.clone(),
192        crate::cli::env_resolver::Source::External => return Ok(None),
193    };
194    let guard = registry.bidi_lock_acquire(&name, std::time::Duration::from_secs(30))?;
195    Ok(Some(guard))
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::config::{self, Config};
202    use crate::detect::{Engine, Kind};
203    use crate::registry::{BrowserRow, Registry};
204    use std::path::PathBuf;
205
206    struct EnvGuard;
207
208    impl Drop for EnvGuard {
209        fn drop(&mut self) {
210            std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
211            std::env::remove_var("BROWSER_CONTROL_CONFIG_DIR");
212        }
213    }
214
215    struct AliveListener {
216        _listener: std::net::TcpListener,
217        port: u16,
218    }
219
220    fn alive_listener() -> AliveListener {
221        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
222        let port = listener.local_addr().unwrap().port();
223        AliveListener {
224            _listener: listener,
225            port,
226        }
227    }
228
229    fn row(name: &str, kind: Kind, port: u16, pid: u32, started_at: &str) -> BrowserRow {
230        BrowserRow {
231            name: name.to_string(),
232            kind,
233            engine: kind.engine(),
234            pid,
235            endpoint: format!("ws://127.0.0.1:{port}/devtools/browser/{name}"),
236            port,
237            profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
238            executable: PathBuf::from("/usr/bin/example"),
239            headless: false,
240            started_at: started_at.to_string(),
241        }
242    }
243
244    fn with_tmp_env<R>(f: impl FnOnce() -> R) -> R {
245        let _lock = crate::test_support::ENV_LOCK
246            .lock()
247            .unwrap_or_else(|e| e.into_inner());
248        let data = tempfile::TempDir::new().unwrap();
249        let cfg = tempfile::TempDir::new().unwrap();
250        std::env::set_var("BROWSER_CONTROL_DATA_DIR", data.path());
251        std::env::set_var("BROWSER_CONTROL_CONFIG_DIR", cfg.path());
252        let _guard = EnvGuard;
253        f()
254    }
255
256    #[test]
257    fn stale_named_default_rewrites_to_kind_and_resolves_live_same_kind() {
258        with_tmp_env(|| {
259            let live = alive_listener();
260            let reg = Registry::open().unwrap();
261            reg.insert(&row(
262                "brave-cosmos",
263                Kind::Brave,
264                9,
265                99_999_999,
266                "2024-01-01T00:00:00Z",
267            ))
268            .unwrap();
269            let live_row = row(
270                "brave-spruce",
271                Kind::Brave,
272                live.port,
273                std::process::id(),
274                "2024-01-02T00:00:00Z",
275            );
276            reg.insert(&live_row).unwrap();
277            config::save(&Config {
278                default: Some("brave-cosmos".into()),
279            })
280            .unwrap();
281            drop(reg);
282
283            let rt = tokio::runtime::Runtime::new().unwrap();
284            let got = rt.block_on(resolve_browser(None)).unwrap();
285            assert_eq!(got.endpoint, live_row.endpoint);
286            assert_eq!(got.engine, Engine::Cdp);
287            assert_eq!(
288                got.source,
289                env_resolver::Source::Registered {
290                    name: "brave-spruce".into()
291                }
292            );
293            assert_eq!(config::load().unwrap().default.as_deref(), Some("brave"));
294            let reg = Registry::open().unwrap();
295            assert!(reg.get_by_name("brave-cosmos").unwrap().is_none());
296        });
297    }
298
299    #[test]
300    fn missing_generated_default_rewrites_to_kind_and_resolves_live_same_kind() {
301        with_tmp_env(|| {
302            let live = alive_listener();
303            let reg = Registry::open().unwrap();
304            let live_row = row(
305                "brave-spruce",
306                Kind::Brave,
307                live.port,
308                std::process::id(),
309                "2024-01-02T00:00:00Z",
310            );
311            reg.insert(&live_row).unwrap();
312            config::save(&Config {
313                default: Some("brave-cosmos".into()),
314            })
315            .unwrap();
316            drop(reg);
317
318            let rt = tokio::runtime::Runtime::new().unwrap();
319            let got = rt.block_on(resolve_browser(None)).unwrap();
320            assert_eq!(got.endpoint, live_row.endpoint);
321            assert_eq!(
322                got.source,
323                env_resolver::Source::Registered {
324                    name: "brave-spruce".into()
325                }
326            );
327            assert_eq!(config::load().unwrap().default.as_deref(), Some("brave"));
328        });
329    }
330}