Skip to content

feat: add browser-connection MCP server#4

Merged
skulidropek merged 1 commit into
mainfrom
feat/browser-connection-mcp
May 24, 2026
Merged

feat: add browser-connection MCP server#4
skulidropek merged 1 commit into
mainfrom
feat/browser-connection-mcp

Conversation

@skulidropek
Copy link
Copy Markdown
Member

Summary

  • add the browser-connection Rust MCP stdio binary for Codex/Hermes configs
  • implement direct CDP-backed browser tools for navigate/snapshot/evaluate/click/type/key/screenshot
  • document command = "browser-connection" as the client-facing product path and add changelog fragment

Verification

  • rust-script scripts/check-changelog-fragment.rs
  • cargo fmt --check
  • cargo check --locked --bins
  • cargo test --locked
  • cargo clippy --locked --all-targets -- -D warnings
  • MCP smoke test: initialize + tools/list through ./target/debug/browser-connection --project dg-my-project --no-start-browser

Notes

This intentionally replaces external upstream Playwright MCP runtime config with the first-party Rust browser-connection command while keeping the existing docker-git-browser-connection lifecycle binary.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 24, 2026

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added browser-connection command, a new MCP server for browser automation workflows integrated with Codex/Hermes.
    • Supports browser automation tools including navigation, screenshots, DOM snapshots, element interaction, and JavaScript execution.
    • CLI options available to configure project namespace, Docker network mode, CDP endpoint, and browser startup behavior.
  • Documentation

    • Updated README with comprehensive setup and configuration guidance for the new workflow.

Walkthrough

This PR introduces a complete Rust-based MCP stdio server called browser-connection that controls Chromium via Chrome DevTools Protocol, replacing external TypeScript Playwright dependencies. It adds CDP browser automation, MCP protocol handling, CLI configuration, integration tests, and comprehensive documentation.

Changes

Browser Connection MCP Implementation

Layer / File(s) Summary
Dependencies and binary configuration
Cargo.toml
Adds serde_json and tungstenite dependencies for JSON and WebSocket, and registers the new browser-connection binary target.
CDP client for browser control
src/cdp.rs
Implements CdpClient with methods to navigate, evaluate JavaScript, snapshot DOM, click elements, type text, press keys, and capture screenshots. Manages page discovery via HTTP endpoints, WebSocket URL rewriting to match configured endpoint, and JSON-RPC request/response handling with error processing and ping/pong support.
Public module exports
src/lib.rs
Exposes cdp and mcp modules as public crate submodules.
MCP stdio server and protocol handling
src/mcp.rs
Implements newline-delimited JSON-RPC server that reads requests from stdin and responds over stdout. Defines McpServerConfig and project ID resolution, dispatches initialize/tools/list/tools/call methods, exposes seven browser automation tools with JSON-schema validation, resolves CDP endpoint from config or by starting a browser, and includes tests verifying server responses exclude Playwright references.
CLI entry point and binary wiring
src/bin/browser-connection.rs
Defines clap CLI struct with options for project selection, Docker network mode, CDP endpoint override, and browser startup control. Main function initializes logging, parses arguments, constructs McpServerConfig, and runs the MCP stdio server with locked stdin/stdout.
Integration tests for MCP binary
tests/mcp_stdio.rs
Adds two integration tests: one validates --help output includes expected flags while excluding forbidden substrings; another spawns the binary with --no-start-browser, sends JSON-RPC messages, and verifies server initialization and tool list responses.
Configuration and documentation updates
README.md, changelog.d/20260524_141210_browser_connection_mcp.md
Rewrites README with Rust-only single-browser workflow, install/verify steps, health-check endpoints, MCP configuration guidance for browser-connection command (replacing external Playwright MCP), smoke test examples, runtime flow diagram, supported tools enumeration, docker-git integration instructions, formal guarantees, and development workflow. Changelog documents the new MCP stdio server and marks prior external Playwright MCP as forbidden.

🎯 3 (Moderate) | ⏱️ ~25 minutes

🐰 A server born in Rust, so quick and sleek,
Controls the browser from stdin to peek—
No more TypeScript, no more replay,
Chrome DevTools Protocol leads the way,
One MCP to rule them all today! 🌐✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a new browser-connection MCP server binary to the project.
Description check ✅ Passed The description clearly relates to the changeset, detailing the new browser-connection MCP stdio binary, CDP-backed tools, documentation updates, and verification steps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/browser-connection-mcp

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
README.md (1)

42-42: ⚡ Quick win

Update comment to reflect the new product path.

Line 42 mentions "MCP Playwright / Hermes" in the context of CDP, which may confuse readers since the README explicitly states that npx @playwright/mcp`` is not the supported path. Consider rewording to just "CDP (for MCP / Hermes)" or "CDP (for browser-connection MCP server)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 42, Update the header text "CDP (for MCP Playwright /
Hermes): http://127.0.0.1:9223" in README.md to remove "Playwright" and clarify
the product path — e.g., change it to "CDP (for MCP / Hermes):
http://127.0.0.1:9223" or "CDP (for browser-connection MCP server):
http://127.0.0.1:9223" so it no longer implies the unsupported `npx
`@playwright/mcp`` path; edit the exact header string in the README accordingly.
src/cdp.rs (1)

205-235: ⚡ Quick win

Hardcoded Host header may cause issues with non-localhost endpoints.

Line 217 hardcodes Host: 127.0.0.1:9222 regardless of the actual endpoint. This could cause problems when:

  • The CDP endpoint is behind a reverse proxy that routes based on Host header
  • The endpoint uses a different port (e.g., 9223 as mentioned in the URL rewriting logic)

Consider deriving the Host header from the actual endpoint URL, or removing it if not strictly necessary.

🔧 Suggested fix
     fn curl_json(&self, method: &str, path: &str) -> Result<Value> {
         let url = format!("{}{}", self.endpoint.trim_end_matches('/'), path);
+        let host_header = self
+            .endpoint
+            .trim()
+            .strip_prefix("http://")
+            .or_else(|| self.endpoint.trim().strip_prefix("https://"))
+            .unwrap_or(&self.endpoint)
+            .trim_end_matches('/');
+        let host_header = format!("Host: {}", host_header);
         let output = Command::new("curl")
             .args([
                 "-sSf",
                 "--connect-timeout",
                 "2",
                 "--max-time",
                 "10",
                 "-X",
                 method,
                 "-H",
-                "Host: 127.0.0.1:9222",
+                &host_header,
                 &url,
             ])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cdp.rs` around lines 205 - 235, The hardcoded "Host: 127.0.0.1:9222"
header in curl_json causes incorrect Host routing for non-local endpoints;
update curl_json to derive the Host header from self.endpoint (parse
self.endpoint into host[:port] and use that in the "-H" arg) or drop the "-H"
header entirely if unnecessary, replacing the literal "Host: 127.0.0.1:9222"
passed to Command::new("curl") so the args built for the curl invocation use the
correct host for the URL variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cdp.rs`:
- Around line 279-314: The send_cdp_command function can hang because
socket.read() may block forever waiting for a matching id; add a
deadline/timeout to break out if no response arrives: after connect(), set a
read timeout on the underlying stream (use socket.get_ref() to access the
TcpStream and call set_read_timeout(Some(timeout))) and also implement an
Instant-based deadline inside the loop (e.g., let deadline = Instant::now() +
timeout; each iteration check if Instant::now() > deadline and return an
Err(anyhow!("timeout waiting for CDP {method} response"))). Keep existing
ping/pong handling and error contexts; use a small sleep or continue to drain
non-id messages until the deadline elapses. Ensure you reference
send_cdp_command, socket.read(), and the id==1 check when making the change.

---

Nitpick comments:
In `@README.md`:
- Line 42: Update the header text "CDP (for MCP Playwright / Hermes):
http://127.0.0.1:9223" in README.md to remove "Playwright" and clarify the
product path — e.g., change it to "CDP (for MCP / Hermes):
http://127.0.0.1:9223" or "CDP (for browser-connection MCP server):
http://127.0.0.1:9223" so it no longer implies the unsupported `npx
`@playwright/mcp`` path; edit the exact header string in the README accordingly.

In `@src/cdp.rs`:
- Around line 205-235: The hardcoded "Host: 127.0.0.1:9222" header in curl_json
causes incorrect Host routing for non-local endpoints; update curl_json to
derive the Host header from self.endpoint (parse self.endpoint into host[:port]
and use that in the "-H" arg) or drop the "-H" header entirely if unnecessary,
replacing the literal "Host: 127.0.0.1:9222" passed to Command::new("curl") so
the args built for the curl invocation use the correct host for the URL
variable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a13dcd2f-1ea1-48af-8b07-c86e9d810c51

📥 Commits

Reviewing files that changed from the base of the PR and between 6e030b7 and 015ee2f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • Cargo.toml
  • README.md
  • changelog.d/20260524_141210_browser_connection_mcp.md
  • src/bin/browser-connection.rs
  • src/cdp.rs
  • src/lib.rs
  • src/mcp.rs
  • tests/mcp_stdio.rs

Comment thread src/cdp.rs
Comment on lines +279 to +314
fn send_cdp_command(websocket_url: &str, method: &str, params: Value) -> Result<Value> {
let (mut socket, _) = connect(websocket_url)
.with_context(|| format!("failed to connect to CDP websocket {websocket_url}"))?;
let request = json!({ "id": 1, "method": method, "params": params });
socket
.send(Message::Text(request.to_string()))
.with_context(|| format!("failed to send CDP command {method}"))?;

loop {
let message = socket
.read()
.with_context(|| format!("failed to read CDP response for {method}"))?;
match message {
Message::Text(text) => {
let value: Value = serde_json::from_str(text.as_ref())
.with_context(|| format!("CDP websocket response for {method} was not JSON"))?;
if value.get("id").and_then(Value::as_u64) == Some(1) {
if let Some(error) = value.get("error") {
return Err(anyhow!("CDP command {method} failed: {error}"));
}
return Ok(value.get("result").cloned().unwrap_or(Value::Null));
}
}
Message::Close(_) => {
return Err(anyhow!("CDP websocket closed before {method} response"))
}
Message::Ping(payload) => {
socket
.send(Message::Pong(payload))
.context("failed to answer CDP websocket ping")?;
}
_ => {}
}
std::thread::sleep(Duration::from_millis(1));
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Missing timeout on WebSocket read loop risks indefinite hang.

The loop at lines 287-313 waits for a CDP response with id: 1, but:

  1. Chrome DevTools Protocol sends many event messages (e.g., Network.*, Page.*) without an id field
  2. If Chrome becomes unresponsive without closing the socket, this loop runs forever
  3. The tungstenite::connect and socket.read() have no configured timeouts

Consider adding a deadline/timeout mechanism to prevent indefinite hangs when Chrome misbehaves.

🛡️ Suggested approach using Instant deadline
+use std::time::Instant;
+
+const CDP_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
+
 fn send_cdp_command(websocket_url: &str, method: &str, params: Value) -> Result<Value> {
     let (mut socket, _) = connect(websocket_url)
         .with_context(|| format!("failed to connect to CDP websocket {websocket_url}"))?;
     let request = json!({ "id": 1, "method": method, "params": params });
     socket
         .send(Message::Text(request.to_string()))
         .with_context(|| format!("failed to send CDP command {method}"))?;
 
+    let deadline = Instant::now() + CDP_COMMAND_TIMEOUT;
     loop {
+        if Instant::now() > deadline {
+            return Err(anyhow!("CDP command {method} timed out after {CDP_COMMAND_TIMEOUT:?}"));
+        }
         let message = socket
             .read()
             .with_context(|| format!("failed to read CDP response for {method}"))?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cdp.rs` around lines 279 - 314, The send_cdp_command function can hang
because socket.read() may block forever waiting for a matching id; add a
deadline/timeout to break out if no response arrives: after connect(), set a
read timeout on the underlying stream (use socket.get_ref() to access the
TcpStream and call set_read_timeout(Some(timeout))) and also implement an
Instant-based deadline inside the loop (e.g., let deadline = Instant::now() +
timeout; each iteration check if Instant::now() > deadline and return an
Err(anyhow!("timeout waiting for CDP {method} response"))). Keep existing
ping/pong handling and error contexts; use a small sleep or continue to drain
non-id messages until the deadline elapses. Ensure you reference
send_cdp_command, socket.read(), and the id==1 check when making the change.

@skulidropek skulidropek merged commit 614339f into main May 24, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant