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