Skip to main content

browser_control/mcp/
playwright.rs

1//! Stdio passthrough for the official `@playwright/mcp` server.
2//!
3//! Spawns the Playwright MCP as a child process, injects `--cdp-endpoint <url>`
4//! resolved from our browser registry, and forwards stdin/stdout/stderr
5//! bidirectionally.
6
7use anyhow::{anyhow, Context, Result};
8use std::process::Stdio;
9use tokio::io::{AsyncRead, AsyncWrite};
10use tokio::process::Command;
11
12use crate::cli::env_resolver::ResolvedBrowser;
13
14/// Spawn the official Playwright MCP server and forward stdio bidirectionally.
15///
16/// Resolves the CDP endpoint URL from `browser.endpoint`. Tries `npx -y @playwright/mcp@latest`,
17/// falling back to `bunx` if `npx` is not on PATH. Injects `--cdp-endpoint <url>` as a CLI arg.
18///
19/// Forwards our process's stdin → child stdin, child stdout → our stdout, child stderr → our stderr.
20/// Returns the child's exit code.
21pub async fn run(browser: &ResolvedBrowser) -> Result<i32> {
22    run_with_streams(
23        browser,
24        tokio::io::stdin(),
25        tokio::io::stdout(),
26        tokio::io::stderr(),
27        None,
28    )
29    .await
30}
31
32/// Same as `run`, but tests can pass custom streams and override the launcher command.
33pub async fn run_with_streams<I, O, E>(
34    browser: &ResolvedBrowser,
35    mut stdin: I,
36    mut stdout: O,
37    mut stderr: E,
38    override_cmd: Option<(String, Vec<String>)>,
39) -> Result<i32>
40where
41    I: AsyncRead + Unpin + Send + 'static,
42    O: AsyncWrite + Unpin + Send + 'static,
43    E: AsyncWrite + Unpin + Send + 'static,
44{
45    let (program, args) = match override_cmd {
46        Some(c) => c,
47        None => choose_launcher(browser)?,
48    };
49
50    let mut child = Command::new(&program)
51        .args(&args)
52        .stdin(Stdio::piped())
53        .stdout(Stdio::piped())
54        .stderr(Stdio::piped())
55        .kill_on_drop(true)
56        .spawn()
57        .with_context(|| format!("failed to spawn `{program}`. Install Node.js (npm) or Bun."))?;
58
59    let mut child_stdin = child
60        .stdin
61        .take()
62        .ok_or_else(|| anyhow!("no child stdin"))?;
63    let mut child_stdout = child
64        .stdout
65        .take()
66        .ok_or_else(|| anyhow!("no child stdout"))?;
67    let mut child_stderr = child
68        .stderr
69        .take()
70        .ok_or_else(|| anyhow!("no child stderr"))?;
71
72    let in_to_child = tokio::spawn(async move {
73        let _ = tokio::io::copy(&mut stdin, &mut child_stdin).await;
74    });
75    let out_to_us = tokio::spawn(async move {
76        let _ = tokio::io::copy(&mut child_stdout, &mut stdout).await;
77    });
78    let err_to_us = tokio::spawn(async move {
79        let _ = tokio::io::copy(&mut child_stderr, &mut stderr).await;
80    });
81
82    let status = child.wait().await?;
83    in_to_child.abort();
84    out_to_us.await.ok();
85    err_to_us.await.ok();
86
87    Ok(status.code().unwrap_or(0))
88}
89
90fn choose_launcher(browser: &ResolvedBrowser) -> Result<(String, Vec<String>)> {
91    let endpoint = browser.endpoint.clone();
92    let args = vec![
93        "-y".into(),
94        "@playwright/mcp@latest".into(),
95        "--cdp-endpoint".into(),
96        endpoint,
97    ];
98    if which::which("npx").is_ok() {
99        Ok(("npx".into(), args))
100    } else if which::which("bunx").is_ok() {
101        Ok(("bunx".into(), args))
102    } else {
103        Err(anyhow!("neither `npx` nor `bunx` is on PATH. Install Node.js (https://nodejs.org/) or Bun (https://bun.sh/)."))
104    }
105}