diff --git a/README.md b/README.md index 88de6db..f640f05 100644 --- a/README.md +++ b/README.md @@ -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`: @@ -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 diff --git a/changelog.d/20260624_071400_personal_browser_targets.md b/changelog.d/20260624_071400_personal_browser_targets.md new file mode 100644 index 0000000..ed2ced7 --- /dev/null +++ b/changelog.d/20260624_071400_personal_browser_targets.md @@ -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. diff --git a/src/bin/browser-connection.rs b/src/bin/browser-connection.rs index 96bc34d..876c8af 100644 --- a/src/bin/browser-connection.rs +++ b/src/bin/browser-connection.rs @@ -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; @@ -30,6 +32,18 @@ struct Cli { #[arg(long)] cdp_endpoint: Option, + /// Register an additional browser target as NAME=CDP_ENDPOINT. Can be repeated. + #[arg(long = "browser", value_name = "NAME=CDP_ENDPOINT")] + browser: Vec, + + /// Register and select a personal browser CDP endpoint, e.g. http://127.0.0.1:9222. + #[arg(long)] + personal_browser: Option, + + /// Browser target to use at startup: managed, explicit, personal, or a --browser name. + #[arg(long)] + active_browser: Option, + /// Do not start/reuse Docker browser on startup; useful for MCP handshake tests. #[arg(long)] no_start_browser: bool, @@ -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(); diff --git a/src/browser_target.rs b/src/browser_target.rs new file mode 100644 index 0000000..48db4e3 --- /dev/null +++ b/src/browser_target.rs @@ -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, cdp_endpoint: impl AsRef) -> Result { + 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 { + 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> { + value + .split([',', ';']) + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(parse_named_browser_endpoint) + .collect() +} + +pub fn browser_endpoints_from_env() -> Result> { + 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 { + 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 { + 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 { + 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, + 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 { + 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 { + 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")); + } +} diff --git a/src/lib.rs b/src/lib.rs index cabde1e..f6ccdf0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ */ mod browser; +mod browser_target; pub mod cdp; pub mod mcp; diff --git a/src/mcp.rs b/src/mcp.rs index 035b271..8e96ed0 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -7,17 +7,26 @@ REF: https://github.com/ProverCoderAI/docker-git/issues/347 SOURCE: n/a FORMAT THEOREM: initialize ∧ tools/list -> MCP-compatible JSON-RPC responses with browser tools. PURITY: SHELL -EFFECT: stdio JSON-RPC and optional CDP/browser Docker startup. -INVARIANT: MCP startup resolves exactly one CDP endpoint from BrowserConnection or an explicit override. +EFFECT: stdio JSON-RPC, optional CDP/browser Docker startup, and active browser target selection. +INVARIANT: browser tools always target the active CDP endpoint: managed, explicit, or configured. */ +use crate::browser_target::{ + configured_browser_kind, normalize_browser_name, normalize_cdp_endpoint, + upsert_browser_endpoint, +}; use crate::cdp::CdpClient; -use crate::{render_cdp_url, BrowserConnection}; +use crate::{compute_browser_ports, render_cdp_url, render_cdp_url_for_ports, BrowserConnection}; use anyhow::{anyhow, Context, Result}; use serde_json::{json, Value}; use std::env; use std::io::{BufRead, Write}; +pub use crate::browser_target::{ + active_browser_from_env, browser_endpoints_from_env, parse_named_browser_endpoint, + parse_named_browser_endpoints, NamedBrowserEndpoint, EXPLICIT_BROWSER_NAME, + MANAGED_BROWSER_NAME, PERSONAL_BROWSER_NAME, +}; pub const SERVER_NAME: &str = "browser-connection"; pub const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; pub const PREVIOUS_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; @@ -36,6 +45,8 @@ pub struct McpServerConfig { pub network: Option, pub cdp_endpoint: Option, pub start_browser: bool, + pub browser_endpoints: Vec, + pub active_browser: Option, } impl McpServerConfig { @@ -50,8 +61,25 @@ impl McpServerConfig { network, cdp_endpoint, start_browser, + browser_endpoints: Vec::new(), + active_browser: None, } } + + pub fn with_browser_endpoints(mut self, endpoints: Vec) -> Self { + for endpoint in endpoints { + upsert_browser_endpoint(&mut self.browser_endpoints, endpoint); + } + self + } + + pub fn with_active_browser(mut self, active_browser: Option) -> Result { + self.active_browser = active_browser + .as_deref() + .map(normalize_browser_name) + .transpose()?; + Ok(self) + } } pub fn project_id_from_env_or_default(project_id: Option) -> String { @@ -74,8 +102,9 @@ where while let Some(message) = read_message(&mut reader, &mut transport)? { match handle_message(&mut runtime, &message) { Ok(Some(response)) => { - let transport = transport - .ok_or_else(|| anyhow!("stdio transport was unknown after reading a message"))?; + let transport = transport.ok_or_else(|| { + anyhow!("stdio transport was unknown after reading a message") + })?; write_message(&mut writer, &response, transport)?; } Ok(None) => {} @@ -97,38 +126,185 @@ where #[derive(Debug, Clone, PartialEq, Eq)] struct McpRuntime { config: McpServerConfig, - cdp_endpoint: Option, + managed_cdp_endpoint: Option, + active_browser: String, } impl McpRuntime { fn new(config: McpServerConfig) -> Self { + let active_browser = config.active_browser.clone().unwrap_or_else(|| { + if has_explicit_cdp_endpoint(&config) { + EXPLICIT_BROWSER_NAME.to_string() + } else { + MANAGED_BROWSER_NAME.to_string() + } + }); + Self { config, - cdp_endpoint: None, + managed_cdp_endpoint: None, + active_browser, } } - fn cdp_endpoint(&mut self) -> Result<&str> { - if self.cdp_endpoint.is_none() { - self.cdp_endpoint = Some(resolve_cdp_endpoint(&self.config)?); + fn cdp_endpoint(&mut self) -> Result { + match self.active_browser.as_str() { + MANAGED_BROWSER_NAME => self.managed_cdp_endpoint(), + EXPLICIT_BROWSER_NAME => explicit_cdp_endpoint(&self.config) + .ok_or_else(|| anyhow!("explicit CDP endpoint is not configured")), + name => self + .config + .browser_endpoints + .iter() + .find(|endpoint| endpoint.name == name) + .map(|endpoint| endpoint.cdp_endpoint.clone()) + .ok_or_else(|| { + anyhow!( + "Unknown browser `{name}`. Available browsers: {}", + self.available_browser_names().join(", ") + ) + }), } + } - self.cdp_endpoint - .as_deref() - .ok_or_else(|| anyhow!("CDP endpoint cache was empty after resolution")) + fn managed_cdp_endpoint(&mut self) -> Result { + if self.managed_cdp_endpoint.is_none() { + self.managed_cdp_endpoint = Some(resolve_managed_cdp_endpoint(&self.config)?); + } + + self.managed_cdp_endpoint + .clone() + .ok_or_else(|| anyhow!("managed CDP endpoint cache was empty after resolution")) } -} -fn resolve_cdp_endpoint(config: &McpServerConfig) -> Result { - if let Some(endpoint) = config - .cdp_endpoint - .as_deref() - .map(str::trim) - .filter(|endpoint| !endpoint.is_empty()) - { - return Ok(endpoint.to_string()); + fn browser_inventory_text(&self) -> Result { + serde_json::to_string_pretty(&self.browser_inventory()) + .context("failed to render browser inventory") + } + + fn browser_inventory(&self) -> Value { + let mut browsers = vec![self.managed_browser_entry()]; + + if let Some(endpoint) = explicit_cdp_endpoint(&self.config) { + browsers.push(browser_entry( + EXPLICIT_BROWSER_NAME, + "explicit", + Some(endpoint), + "configured", + self.active_browser == EXPLICIT_BROWSER_NAME, + )); + } + + for endpoint in &self.config.browser_endpoints { + browsers.push(browser_entry( + &endpoint.name, + configured_browser_kind(&endpoint.name), + Some(endpoint.cdp_endpoint.clone()), + "configured", + self.active_browser == endpoint.name, + )); + } + + json!({ + "active": self.active_browser, + "browsers": browsers + }) } + fn managed_browser_entry(&self) -> Value { + let cached_endpoint = self.managed_cdp_endpoint.clone(); + let endpoint = cached_endpoint + .clone() + .or_else(|| (!self.config.start_browser).then(render_cdp_url)) + .or_else(|| { + let ports = compute_browser_ports(&self.config.project_id); + Some(render_cdp_url_for_ports(ports)) + }); + let resolution = if cached_endpoint.is_some() { + "resolved" + } else if self.config.start_browser { + "auto-start" + } else { + "default-localhost" + }; + + browser_entry( + MANAGED_BROWSER_NAME, + "managed", + endpoint, + resolution, + self.active_browser == MANAGED_BROWSER_NAME, + ) + } + + fn select_browser(&mut self, name: &str, cdp_endpoint: Option<&str>) -> Result { + let name = normalize_browser_name(name)?; + + if let Some(endpoint) = cdp_endpoint { + if name == MANAGED_BROWSER_NAME || name == EXPLICIT_BROWSER_NAME { + return Err(anyhow!( + "`{name}` is reserved; choose a custom name such as `{PERSONAL_BROWSER_NAME}`" + )); + } + let endpoint = NamedBrowserEndpoint::new(&name, endpoint)?; + upsert_browser_endpoint(&mut self.config.browser_endpoints, endpoint); + } else { + self.ensure_browser_exists(&name)?; + } + + self.active_browser = name; + serde_json::to_string_pretty(&json!({ + "selected": self.active_browser, + "browser": self.browser_inventory() + .get("browsers") + .and_then(Value::as_array) + .and_then(|browsers| browsers.iter().find(|browser| { + browser.get("name").and_then(Value::as_str) == Some(self.active_browser.as_str()) + })) + .cloned() + .unwrap_or(Value::Null) + })) + .context("failed to render browser selection") + } + + fn ensure_browser_exists(&self, name: &str) -> Result<()> { + if name == MANAGED_BROWSER_NAME { + return Ok(()); + } + if name == EXPLICIT_BROWSER_NAME && has_explicit_cdp_endpoint(&self.config) { + return Ok(()); + } + if self + .config + .browser_endpoints + .iter() + .any(|endpoint| endpoint.name == name) + { + return Ok(()); + } + + Err(anyhow!( + "Unknown browser `{name}`. Available browsers: {}", + self.available_browser_names().join(", ") + )) + } + + fn available_browser_names(&self) -> Vec { + let mut names = vec![MANAGED_BROWSER_NAME.to_string()]; + if has_explicit_cdp_endpoint(&self.config) { + names.push(EXPLICIT_BROWSER_NAME.to_string()); + } + names.extend( + self.config + .browser_endpoints + .iter() + .map(|endpoint| endpoint.name.clone()), + ); + names + } +} + +fn resolve_managed_cdp_endpoint(config: &McpServerConfig) -> Result { if !config.start_browser { return Ok(render_cdp_url()); } @@ -138,6 +314,33 @@ fn resolve_cdp_endpoint(config: &McpServerConfig) -> Result { Ok(info.cdp_url) } +fn explicit_cdp_endpoint(config: &McpServerConfig) -> Option { + config + .cdp_endpoint + .as_deref() + .and_then(|endpoint| normalize_cdp_endpoint(endpoint).ok()) +} + +fn has_explicit_cdp_endpoint(config: &McpServerConfig) -> bool { + explicit_cdp_endpoint(config).is_some() +} + +fn browser_entry( + name: &str, + kind: &str, + cdp_endpoint: Option, + resolution: &str, + active: bool, +) -> Value { + json!({ + "name": name, + "kind": kind, + "cdpEndpoint": cdp_endpoint, + "resolution": resolution, + "active": active + }) +} + fn read_message( reader: &mut R, transport: &mut Option, @@ -340,9 +543,24 @@ fn initialize_result(protocol_version: &str) -> Value { fn tool_definitions() -> Vec { vec![ + tool( + "browser_list", + "List available browser targets and show which target is active.", + json!({}), + vec![], + ), + tool( + "browser_select", + "Switch the active browser target by name, optionally registering a CDP endpoint first.", + json!({ + "name": { "type": "string", "description": "Browser target name, e.g. managed or personal" }, + "cdp_endpoint": { "type": "string", "description": "Optional CDP endpoint to register for this browser name" } + }), + vec!["name"], + ), tool( "browser_navigate", - "Navigate the noVNC-visible Chromium page to a URL through the Rust CDP adapter.", + "Navigate the active browser page to a URL through the Rust CDP adapter.", json!({ "url": { "type": "string", "description": "Absolute URL to open" } }), vec!["url"], ), @@ -405,9 +623,16 @@ fn handle_tool_call(runtime: &mut McpRuntime, request: &Value) -> Value { let name = params.get("name").and_then(Value::as_str).unwrap_or(""); let arguments = params.get("arguments").unwrap_or(&Value::Null); - let result = runtime - .cdp_endpoint() - .and_then(|cdp_endpoint| dispatch_tool(cdp_endpoint, name, arguments)); + let result = match name { + "browser_list" => runtime.browser_inventory_text(), + "browser_select" => runtime.select_browser( + required_str(arguments, "name").unwrap_or(""), + arguments.get("cdp_endpoint").and_then(Value::as_str), + ), + _ => runtime + .cdp_endpoint() + .and_then(|cdp_endpoint| dispatch_tool(&cdp_endpoint, name, arguments)), + }; match result { Ok(text) => tool_result(text, false), Err(error) => tool_result(format!("{error:#}"), true), @@ -488,7 +713,9 @@ mod tests { let mut responses = Vec::new(); let mut transport = Some(StdioTransport::Framed); - while let Some(message) = read_message(&mut cursor, &mut transport).expect("stdout frame parses") { + while let Some(message) = + read_message(&mut cursor, &mut transport).expect("stdout frame parses") + { responses.push(serde_json::from_str(&message).expect("stdout frame body is JSON")); } @@ -596,7 +823,8 @@ mod tests { ) .expect("tools/list succeeds"); - assert_eq!(runtime.cdp_endpoint, None); + assert_eq!(runtime.managed_cdp_endpoint, None); + assert_eq!(runtime.active_browser, MANAGED_BROWSER_NAME); } #[test] @@ -612,7 +840,10 @@ mod tests { .expect("request with id returns a response"); assert_eq!(response["result"]["protocolVersion"], "2024-11-05"); - assert_eq!(response["result"]["capabilities"]["tools"]["listChanged"], false); + assert_eq!( + response["result"]["capabilities"]["tools"]["listChanged"], + false + ); } #[test] @@ -678,7 +909,7 @@ mod tests { .expect("request with id returns a response"); assert_eq!( - runtime.cdp_endpoint.as_deref(), + runtime.managed_cdp_endpoint.as_deref(), Some(expected_cdp_url.as_str()) ); assert_eq!(response["id"], 3); @@ -688,4 +919,71 @@ mod tests { .expect("tool result text exists") .contains("Unknown browser-connection tool")); } + + #[test] + fn configured_personal_browser_can_be_active_without_docker() { + let config = McpServerConfig::new("dg-test", None, None, true) + .with_browser_endpoints(vec![NamedBrowserEndpoint::new( + PERSONAL_BROWSER_NAME, + "http://127.0.0.1:9333", + ) + .expect("endpoint is valid")]) + .with_active_browser(Some(PERSONAL_BROWSER_NAME.to_string())) + .expect("active browser name is valid"); + let mut runtime = McpRuntime::new(config); + + assert_eq!( + runtime.cdp_endpoint().expect("personal endpoint resolves"), + "http://127.0.0.1:9333" + ); + assert_eq!(runtime.managed_cdp_endpoint, None); + } + + #[test] + fn browser_select_registers_ad_hoc_personal_endpoint_without_resolving_managed_browser() { + let config = McpServerConfig::new("dg-test", None, None, true); + let mut runtime = McpRuntime::new(config); + + let response = handle_message( + &mut runtime, + r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"browser_select","arguments":{"name":"personal","cdp_endpoint":"http://127.0.0.1:9444"}}}"#, + ) + .expect("browser_select response serializes") + .expect("request with id returns a response"); + + assert_eq!(response["id"], 4); + assert_eq!(response["result"]["isError"], false); + assert_eq!(runtime.active_browser, PERSONAL_BROWSER_NAME); + assert_eq!(runtime.managed_cdp_endpoint, None); + assert_eq!( + runtime.cdp_endpoint().expect("personal endpoint resolves"), + "http://127.0.0.1:9444" + ); + } + + #[test] + fn browser_list_reports_active_personal_browser() { + let config = McpServerConfig::new("dg-test", None, None, false) + .with_browser_endpoints(vec![NamedBrowserEndpoint::new( + PERSONAL_BROWSER_NAME, + "http://127.0.0.1:9222", + ) + .expect("endpoint is valid")]) + .with_active_browser(Some(PERSONAL_BROWSER_NAME.to_string())) + .expect("active browser name is valid"); + let runtime = McpRuntime::new(config); + + let inventory = runtime.browser_inventory(); + + assert_eq!(inventory["active"], PERSONAL_BROWSER_NAME); + assert!(inventory["browsers"] + .as_array() + .expect("browsers array") + .iter() + .any(|browser| { + browser["name"] == PERSONAL_BROWSER_NAME + && browser["kind"] == "personal" + && browser["active"] == true + })); + } } diff --git a/tests/mcp_stdio.rs b/tests/mcp_stdio.rs index 05b7174..6f4e84b 100644 --- a/tests/mcp_stdio.rs +++ b/tests/mcp_stdio.rs @@ -60,6 +60,9 @@ fn browser_connection_help_exposes_custom_mcp_command_without_npx() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("browser-connection")); assert!(stdout.contains("--project")); + assert!(stdout.contains("--browser")); + assert!(stdout.contains("--personal-browser")); + assert!(stdout.contains("--active-browser")); assert!(stdout.contains("--no-start-browser")); assert!(!stdout.contains("playwright/mcp")); assert!(!stdout.contains("npx")); @@ -144,6 +147,87 @@ fn browser_connection_stdio_initializes_and_lists_browser_tools_without_docker() assert!(names.contains(&"browser_type")); assert!(names.contains(&"browser_press_key")); assert!(names.contains(&"browser_take_screenshot")); + assert!(names.contains(&"browser_list")); + assert!(names.contains(&"browser_select")); +} + +#[test] +fn browser_connection_stdio_selects_personal_browser_without_docker() { + let mut child = Command::new(env!("CARGO_BIN_EXE_browser-connection")) + .args(["--project", "dg-test", "--no-start-browser"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to spawn browser-connection MCP server"); + + { + let stdin = child.stdin.as_mut().expect("stdin is piped"); + let mut input = Vec::new(); + input.extend(encode_message(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "probe", "version": "0" } + } + }))); + input.extend(encode_message(serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "browser_select", + "arguments": { + "name": "personal", + "cdp_endpoint": "http://127.0.0.1:9444/json/version" + } + } + }))); + input.extend(encode_message(serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "browser_list", + "arguments": {} + } + }))); + stdin + .write_all(&input) + .expect("write MCP browser selection requests"); + } + drop(child.stdin.take()); + + let output = child + .wait_with_output() + .expect("browser-connection process exits after stdin EOF"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let responses = decode_messages(&output.stdout); + assert_eq!(responses.len(), 3); + assert_eq!(responses[1]["result"]["isError"], false); + let inventory_text = responses[2]["result"]["content"][0]["text"] + .as_str() + .expect("browser_list returns text content"); + let inventory: Value = serde_json::from_str(inventory_text).expect("browser_list text is JSON"); + + assert_eq!(inventory["active"], "personal"); + assert!(inventory["browsers"] + .as_array() + .expect("browsers array") + .iter() + .any(|browser| { + browser["name"] == "personal" + && browser["cdpEndpoint"] == "http://127.0.0.1:9444" + && browser["active"] == true + })); } #[test]