Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,43 @@ args = ["--project", "dg-my-project"]

Use `browser-connection`, not `npx @playwright/mcp`. The MCP server starts/reuses the same Rust-managed browser container automatically.

## Personal browser

You can attach an already running desktop Chrome/Chromium browser if it exposes a CDP port:

```bash
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.browser-connection-personal"
```

Then point the MCP server at that browser:

```toml
[mcp_servers.playwright]
command = "browser-connection"
args = ["--project", "dg-my-project", "--personal-browser", "http://127.0.0.1:9222"]
```

Multiple browser targets can be configured and switched at runtime:

```toml
[mcp_servers.playwright]
command = "browser-connection"
args = [
"--project", "dg-my-project",
"--browser", "personal=http://127.0.0.1:9222",
"--browser", "work=http://127.0.0.1:9333",
"--active-browser", "personal",
]
```

Environment alternatives:

```bash
export BROWSER_CONNECTION_PERSONAL_CDP_ENDPOINT=http://127.0.0.1:9222
export BROWSER_CONNECTION_BROWSERS=work=http://127.0.0.1:9333
export BROWSER_CONNECTION_ACTIVE_BROWSER=personal
```

## Hermes MCP config

`~/.hermes/config.yaml`:
Expand All @@ -70,8 +107,12 @@ browser_click(selector)
browser_type(selector, text)
browser_press_key(key)
browser_take_screenshot(full_page?)
browser_list()
browser_select(name, cdp_endpoint?)
```

Use `browser_select` with `name=managed` to return to the Rust-managed noVNC/CDP browser, or with `name=personal` and `cdp_endpoint=http://127.0.0.1:9222` to connect a personal browser without restarting the MCP server.

## Smoke test

```bash
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/20260624_071400_personal_browser_targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
bump: minor
---

### Added
- Add named MCP browser targets so agents can connect to a personal CDP browser and switch between personal, external, and Rust-managed browser sessions at runtime.
36 changes: 34 additions & 2 deletions src/bin/browser-connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

use clap::Parser;
use docker_git_browser_connection::mcp::{
project_id_from_env_or_default, run_stdio, McpServerConfig, SERVER_NAME,
active_browser_from_env, browser_endpoints_from_env, parse_named_browser_endpoint,
project_id_from_env_or_default, run_stdio, McpServerConfig, NamedBrowserEndpoint,
PERSONAL_BROWSER_NAME, SERVER_NAME,
};
use std::io;

Expand All @@ -30,6 +32,18 @@ struct Cli {
#[arg(long)]
cdp_endpoint: Option<String>,

/// Register an additional browser target as NAME=CDP_ENDPOINT. Can be repeated.
#[arg(long = "browser", value_name = "NAME=CDP_ENDPOINT")]
browser: Vec<String>,

/// Register and select a personal browser CDP endpoint, e.g. http://127.0.0.1:9222.
#[arg(long)]
personal_browser: Option<String>,

/// Browser target to use at startup: managed, explicit, personal, or a --browser name.
#[arg(long)]
active_browser: Option<String>,

/// Do not start/reuse Docker browser on startup; useful for MCP handshake tests.
#[arg(long)]
no_start_browser: bool,
Expand All @@ -38,12 +52,30 @@ struct Cli {
fn main() -> anyhow::Result<()> {
env_logger::init();
let cli = Cli::parse();
let mut browser_endpoints = browser_endpoints_from_env()?;
let mut active_browser = active_browser_from_env();

for browser in &cli.browser {
browser_endpoints.push(parse_named_browser_endpoint(browser)?);
}

if let Some(endpoint) = cli.personal_browser {
browser_endpoints.push(NamedBrowserEndpoint::new(PERSONAL_BROWSER_NAME, endpoint)?);
active_browser = Some(PERSONAL_BROWSER_NAME.to_string());
}

if cli.active_browser.is_some() {
active_browser = cli.active_browser;
}

let config = McpServerConfig::new(
project_id_from_env_or_default(cli.project),
cli.network,
cli.cdp_endpoint,
!cli.no_start_browser,
);
)
.with_browser_endpoints(browser_endpoints)
.with_active_browser(active_browser)?;

let stdin = io::stdin();
let stdout = io::stdout();
Expand Down
149 changes: 149 additions & 0 deletions src/browser_target.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
use anyhow::{anyhow, Result};
use std::env;

pub const MANAGED_BROWSER_NAME: &str = "managed";
pub const EXPLICIT_BROWSER_NAME: &str = "explicit";
pub const PERSONAL_BROWSER_NAME: &str = "personal";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamedBrowserEndpoint {
pub name: String,
pub cdp_endpoint: String,
}

impl NamedBrowserEndpoint {
pub fn new(name: impl AsRef<str>, cdp_endpoint: impl AsRef<str>) -> Result<Self> {
let name = normalize_configured_browser_name(name.as_ref())?;
let cdp_endpoint = normalize_cdp_endpoint(cdp_endpoint.as_ref())?;
Ok(Self { name, cdp_endpoint })
}
}

pub fn parse_named_browser_endpoint(value: &str) -> Result<NamedBrowserEndpoint> {
let (name, endpoint) = value
.split_once('=')
.ok_or_else(|| anyhow!("browser endpoint must use NAME=CDP_ENDPOINT format"))?;
NamedBrowserEndpoint::new(name, endpoint)
}

pub fn parse_named_browser_endpoints(value: &str) -> Result<Vec<NamedBrowserEndpoint>> {
value
.split([',', ';'])
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(parse_named_browser_endpoint)
.collect()
}

pub fn browser_endpoints_from_env() -> Result<Vec<NamedBrowserEndpoint>> {
let mut endpoints = Vec::new();

if let Some(value) = nonempty_env("BROWSER_CONNECTION_BROWSERS") {
endpoints.extend(parse_named_browser_endpoints(&value)?);
}

if let Some(endpoint) = nonempty_env("BROWSER_CONNECTION_PERSONAL_CDP_ENDPOINT") {
endpoints.push(NamedBrowserEndpoint::new(PERSONAL_BROWSER_NAME, endpoint)?);
}

Ok(endpoints)
}

pub fn active_browser_from_env() -> Option<String> {
nonempty_env("BROWSER_CONNECTION_ACTIVE_BROWSER")
}

pub(crate) fn configured_browser_kind(name: &str) -> &'static str {
if name == PERSONAL_BROWSER_NAME {
"personal"
} else {
"external"
}
}

pub(crate) fn normalize_browser_name(name: &str) -> Result<String> {
let name = name.trim();
if name.is_empty() {
return Err(anyhow!("browser name must not be empty"));
}
if !name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
{
return Err(anyhow!(
"browser name `{name}` may only contain ASCII letters, digits, '.', '_' or '-'"
));
}
Ok(name.to_string())
}

pub(crate) fn normalize_cdp_endpoint(endpoint: &str) -> Result<String> {
let endpoint = endpoint.trim().trim_end_matches('/');
if endpoint.is_empty() {
return Err(anyhow!("CDP endpoint must not be empty"));
}
let endpoint = endpoint
.strip_suffix("/json/version")
.or_else(|| endpoint.strip_suffix("/json/list"))
.unwrap_or(endpoint)
.trim_end_matches('/');
if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) {
return Err(anyhow!(
"CDP endpoint `{endpoint}` must start with http:// or https://"
));
}
Ok(endpoint.to_string())
}

pub(crate) fn upsert_browser_endpoint(
endpoints: &mut Vec<NamedBrowserEndpoint>,
endpoint: NamedBrowserEndpoint,
) {
if let Some(existing) = endpoints
.iter_mut()
.find(|existing| existing.name == endpoint.name)
{
*existing = endpoint;
} else {
endpoints.push(endpoint);
}
}

fn normalize_configured_browser_name(name: &str) -> Result<String> {
let name = normalize_browser_name(name)?;
if name == MANAGED_BROWSER_NAME || name == EXPLICIT_BROWSER_NAME {
return Err(anyhow!(
"`{name}` is reserved and cannot name a configured browser"
));
}
Ok(name)
}

fn nonempty_env(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_named_personal_browser_endpoint() {
let endpoint = parse_named_browser_endpoint("personal=http://127.0.0.1:9222/json/version")
.expect("personal endpoint parses");

assert_eq!(endpoint.name, PERSONAL_BROWSER_NAME);
assert_eq!(endpoint.cdp_endpoint, "http://127.0.0.1:9222");
}

#[test]
fn rejects_reserved_configured_browser_names() {
let error = NamedBrowserEndpoint::new(MANAGED_BROWSER_NAME, "http://127.0.0.1:9222")
.expect_err("managed is reserved");

assert!(error.to_string().contains("reserved"));
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

mod browser;
mod browser_target;
pub mod cdp;
pub mod mcp;

Expand Down
Loading
Loading