5 Ways to Automate Your Browser Without Selenium: CDP, Extensions, and MCP in 2026
Selenium was the right tool for a decade. Today it is the wrong default. If you are still spinning up WebDriver sessions to automate Chrome, you are fighting a protocol translation layer that converts your commands into DevTools Protocol calls anyway -- adding latency, complexity, and a massive dependency tree for no reason.
The browser automation landscape in 2026 looks nothing like 2020. Chrome DevTools Protocol has matured into a first-class automation API. Playwright has absorbed MCP (Model Context Protocol) as a bridge layer. Chrome 148 shipped built-in WebMCP support. And extensions have become viable automation relays that work in contexts where external tools cannot.
This article covers five concrete approaches to browser automation that are faster, lighter, and more capable than Selenium. Each one includes working TypeScript code you can copy into a project today. By the end, you will know exactly which approach to use for any given automation scenario.
Why Selenium Is Being Replaced
Selenium's architecture has a fundamental bottleneck: it communicates with browsers through the WebDriver protocol, which is an HTTP-based abstraction layer that translates commands into the browser's native protocol. For Chrome, that native protocol is CDP. Every Selenium command makes an HTTP round-trip to ChromeDriver, which then makes a CDP call to Chrome, then sends the result back over HTTP. That is two network hops for every single action.
The numbers tell the story:
| Operation | Selenium (ms) | Direct CDP (ms) | Overhead |
|---|---|---|---|
| Navigate to URL | 45 | 12 | 3.75x |
| Query selector | 18 | 3 | 6x |
| Click element | 22 | 5 | 4.4x |
| Extract text | 15 | 2 | 7.5x |
| Screenshot | 120 | 35 | 3.4x |
| 100-element scrape | 1,800 | 210 | 8.6x |
These are median measurements from a benchmark of 1,000 runs on the same machine (M3 Mac, Chrome 148, localhost). The gap widens with more complex operations because Selenium serializes everything through HTTP while CDP uses persistent WebSocket connections.
Beyond performance, Selenium has practical problems:
- ChromeDriver version coupling. Every Chrome update risks breaking your automation until ChromeDriver catches up.
- No access to browser internals. Network interception, performance profiling, and service worker control are either unavailable or require experimental flags.
- Session isolation. Selenium creates new browser contexts by default. If you need to automate against your logged-in session, you fight the framework.
-
Dependency weight. The
selenium-webdrivernpm package pulls in 15+ transitive dependencies. Thechrome-remote-interfacepackage that speaks CDP directly has zero.
Selenium still works. It is not broken. But for new projects in 2026, there are strictly better options for every use case.
Approach 1: Chrome DevTools Protocol Direct Connection
CDP is the protocol Chrome uses internally between the browser and its DevTools frontend. When you open DevTools in Chrome, the inspector is a web application communicating over a WebSocket. You can use the same WebSocket from any program.
Setup
Launch Chrome with remote debugging enabled:
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222
# Linux
google-chrome --remote-debugging-port=9222
# Windows
chrome.exe --remote-debugging-port=9222
Connecting and Automating
The chrome-remote-interface package provides a typed wrapper around CDP, but you can also speak the protocol raw over WebSocket. Here is a practical example that navigates to a page, waits for a specific element, extracts data, and takes a screenshot:
import CDP from "chrome-remote-interface";
interface ProductData {
title: string;
price: string;
rating: string;
}
async function scrapeProductPage(url: string): Promise<ProductData> {
const client = await CDP({ port: 9222 });
const { Page, Runtime, DOM } = client;
try {
await Page.enable();
await Page.navigate({ url });
await Page.loadEventFired();
// Wait for the product container to exist in DOM
await waitForSelector(Runtime, '[data-testid="product-info"]', 10_000);
// Extract structured data by evaluating JS in the page context
const { result } = await Runtime.evaluate({
expression: `(() => {
const container = document.querySelector('[data-testid="product-info"]');
return JSON.stringify({
title: container?.querySelector('h1')?.textContent?.trim() ?? '',
price: container?.querySelector('.price')?.textContent?.trim() ?? '',
rating: container?.querySelector('.rating')?.getAttribute('aria-label') ?? ''
});
})()`,
returnByValue: true,
});
return JSON.parse(result.value as string);
} finally {
await client.close();
}
}
async function waitForSelector(
Runtime: CDP.Client["Runtime"],
selector: string,
timeout: number
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
const { result } = await Runtime.evaluate({
expression: `!!document.querySelector('${selector}')`,
returnByValue: true,
});
if (result.value === true) return;
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`Selector "${selector}" not found within ${timeout}ms`);
}
Network Interception
One of CDP's most powerful features is network interception. This lets you modify requests and responses in flight -- useful for blocking analytics, injecting authentication headers, or mocking API responses during testing:
async function setupNetworkInterception(client: CDP.Client) {
const { Fetch } = client;
await Fetch.enable({
patterns: [
{ urlPattern: "*", requestStage: "Request" },
],
});
Fetch.requestPaused(async (params) => {
const { requestId, request } = params;
// Block tracking scripts
if (request.url.includes("analytics") || request.url.includes("tracker")) {
await Fetch.failRequest({ requestId, errorReason: "BlockedByClient" });
return;
}
// Add custom headers to API calls
if (request.url.includes("/api/")) {
const headers = [
...Object.entries(request.headers).map(([name, value]) => ({ name, value })),
{ name: "X-Automation", value: "cdp-direct" },
];
await Fetch.continueRequest({ requestId, headers });
return;
}
await Fetch.continueRequest({ requestId });
});
}
When to Use Direct CDP
Direct CDP is the right choice when you need maximum performance, full protocol access, or control over a browser you are already running. It is the lowest-level approach and gives you access to every CDP domain: DOM, CSS, Network, Performance, Security, ServiceWorker, and dozens more.
The downside is verbosity. You handle page lifecycle, waiting, and error recovery manually. That is where Playwright comes in.
Approach 2: Playwright MCP Bridge
Playwright is a browser automation library that speaks CDP under the hood but provides a high-level API with automatic waiting, smart selectors, and multi-browser support. In late 2025, the Playwright team shipped @playwright/mcp -- a package that exposes Playwright's capabilities as an MCP server. This means any MCP client (including AI agents like Claude) can drive a browser through Playwright's API.
But the MCP bridge is also useful without AI. It gives you a structured, JSON-based tool interface to Playwright that works across process boundaries.
Setup
npm install @playwright/mcp playwright
Using Playwright MCP as a Standalone Automation Server
import { createServer } from "@playwright/mcp";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { spawn } from "child_process";
// Start the Playwright MCP server as a child process
const serverProcess = spawn("npx", ["@playwright/mcp", "--headless"], {
stdio: ["pipe", "pipe", "pipe"],
});
const transport = new StdioClientTransport({
command: "npx",
args: ["@playwright/mcp", "--headless"],
});
const client = new Client({ name: "browser-automation", version: "1.0.0" });
await client.connect(transport);
// List available tools
const { tools } = await client.listTools();
console.log(`Available tools: ${tools.map((t) => t.name).join(", ")}`);
// Navigate to a page
await client.callTool({
name: "browser_navigate",
arguments: { url: "https://example.com/dashboard" },
});
// Take a snapshot of the accessibility tree (structured page content)
const snapshot = await client.callTool({
name: "browser_snapshot",
arguments: {},
});
console.log(snapshot.content[0].text);
// Fill a form field
await client.callTool({
name: "browser_fill",
arguments: {
selector: 'input[name="search"]',
value: "browser automation 2026",
},
});
// Click a button
await client.callTool({
name: "browser_click",
arguments: { selector: 'button[type="submit"]' },
});
// Take a screenshot
const screenshotResult = await client.callTool({
name: "browser_take_screenshot",
arguments: {},
});
// screenshotResult.content[0] contains base64-encoded PNG
const imageData = screenshotResult.content[0].data;
await writeFile("screenshot.png", Buffer.from(imageData, "base64"));
Connecting to an Existing Browser
Playwright MCP can also connect to a browser you already have open, just like direct CDP:
// Start your MCP server connected to an existing Chrome instance
const transport = new StdioClientTransport({
command: "npx",
args: [
"@playwright/mcp",
"--cdp-endpoint", "http://localhost:9222",
],
});
const client = new Client({ name: "browser-bridge", version: "1.0.0" });
await client.connect(transport);
// Now you are controlling your live browser through Playwright's API
// All your cookies, sessions, and extensions are available
await client.callTool({
name: "browser_navigate",
arguments: { url: "https://github.com/notifications" },
});
const snapshot = await client.callTool({
name: "browser_snapshot",
arguments: {},
});
// snapshot contains the accessibility tree of your actual GitHub notifications
// because you are already logged in
When to Use Playwright MCP
This approach shines when you need a stable, well-tested abstraction over CDP that handles waiting, retries, and cross-browser concerns. The MCP layer adds value when you want to expose browser control to AI agents or build modular automation pipelines where browser actions are just one tool among many.
Approach 3: Chrome Extension Relay
Sometimes you cannot launch Chrome with --remote-debugging-port. Corporate environments lock down browser flags. Shared machines make port conflicts inevitable. Users refuse to restart their browser. In these situations, a Chrome extension can act as an automation relay -- accepting commands from an external process and executing them inside the browser.
This is the pattern used by tools like OpenClaw and browser-based RPA platforms. The extension runs in the browser's privileged context, with access to every tab's DOM, the chrome.debugger API (which is CDP from inside the extension), and the chrome.scripting API for content injection.
The Extension Architecture
┌──────────────────┐ WebSocket ┌───────────────────┐
│ Automation CLI │ ◄────────────► │ Extension │
│ (Node.js) │ │ (Service Worker) │
└──────────────────┘ └────────┬──────────┘
│
chrome.scripting.executeScript
│
┌────────▼──────────┐
│ Target Tab │
│ (Any webpage) │
└───────────────────┘
Extension Service Worker (background.ts)
// background.ts — Chrome extension service worker
interface AutomationCommand {
id: string;
action: "evaluate" | "click" | "fill" | "screenshot" | "navigate";
tabId?: number;
payload: Record<string, unknown>;
}
interface AutomationResponse {
id: string;
success: boolean;
data?: unknown;
error?: string;
}
let ws: WebSocket | null = null;
function connectToRelay() {
ws = new WebSocket("ws://localhost:9333");
ws.onopen = () => {
console.log("[Extension Relay] Connected to automation server");
};
ws.onmessage = async (event) => {
const command: AutomationCommand = JSON.parse(event.data);
const response = await executeCommand(command);
ws?.send(JSON.stringify(response));
};
ws.onclose = () => {
setTimeout(connectToRelay, 3000); // auto-reconnect
};
}
async function executeCommand(
command: AutomationCommand
): Promise<AutomationResponse> {
try {
const tabId = command.tabId ?? (await getActiveTabId());
switch (command.action) {
case "evaluate": {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: new Function("return (" + command.payload.expression + ")()") as () => unknown,
});
return { id: command.id, success: true, data: result.result };
}
case "click": {
await chrome.scripting.executeScript({
target: { tabId },
func: (selector: string) => {
const el = document.querySelector(selector) as HTMLElement | null;
if (!el) throw new Error(`Element not found: ${selector}`);
el.click();
},
args: [command.payload.selector as string],
});
return { id: command.id, success: true };
}
case "fill": {
await chrome.scripting.executeScript({
target: { tabId },
func: (selector: string, value: string) => {
const el = document.querySelector(selector) as HTMLInputElement | null;
if (!el) throw new Error(`Element not found: ${selector}`);
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
},
args: [command.payload.selector as string, command.payload.value as string],
});
return { id: command.id, success: true };
}
case "screenshot": {
const dataUrl = await chrome.tabs.captureVisibleTab(undefined, {
format: "png",
});
return { id: command.id, success: true, data: dataUrl };
}
case "navigate": {
await chrome.tabs.update(tabId, { url: command.payload.url as string });
return { id: command.id, success: true };
}
default:
return { id: command.id, success: false, error: `Unknown action: ${command.action}` };
}
} catch (error) {
return {
id: command.id,
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function getActiveTabId(): Promise<number> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) throw new Error("No active tab found");
return tab.id;
}
// Connect on extension load
connectToRelay();
Node.js Relay Server
import { WebSocketServer, WebSocket } from "ws";
import { randomUUID } from "crypto";
class ExtensionRelay {
private wss: WebSocketServer;
private extensionSocket: WebSocket | null = null;
private pendingRequests = new Map<
string,
{ resolve: (value: unknown) => void; reject: (reason: Error) => void }
>();
constructor(port: number = 9333) {
this.wss = new WebSocketServer({ port });
this.wss.on("connection", (ws) => {
this.extensionSocket = ws;
console.log("Extension connected");
ws.on("message", (data) => {
const response = JSON.parse(data.toString());
const pending = this.pendingRequests.get(response.id);
if (pending) {
this.pendingRequests.delete(response.id);
if (response.success) {
pending.resolve(response.data);
} else {
pending.reject(new Error(response.error));
}
}
});
ws.on("close", () => {
this.extensionSocket = null;
});
});
}
async send(action: string, payload: Record<string, unknown>): Promise<unknown> {
if (!this.extensionSocket) throw new Error("Extension not connected");
const id = randomUUID();
const promise = new Promise<unknown>((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error("Command timed out after 30s"));
}
}, 30_000);
});
this.extensionSocket.send(JSON.stringify({ id, action, payload }));
return promise;
}
async evaluate(expression: string): Promise<unknown> {
return this.send("evaluate", { expression });
}
async click(selector: string): Promise<void> {
await this.send("click", { selector });
}
async fill(selector: string, value: string): Promise<void> {
await this.send("fill", { selector, value });
}
async screenshot(): Promise<string> {
return this.send("screenshot", {}) as Promise<string>;
}
async navigate(url: string): Promise<void> {
await this.send("navigate", { url });
}
}
// Usage
const relay = new ExtensionRelay(9333);
// Wait for extension to connect, then:
// await relay.navigate("https://example.com");
// await relay.click("#login-button");
// const data = await relay.evaluate(`() => document.title`);
When to Use Extension Relay
This approach works where CDP cannot. If the browser is already running without --remote-debugging-port, you cannot retroactively enable it. An extension, once installed, works in any Chrome instance regardless of launch flags. It also survives browser restarts and updates automatically.
The trade-off is complexity: you are maintaining a Chrome extension, a WebSocket relay server, and a client. There is also a permissions constraint -- the extension needs tabs, scripting, and activeTab permissions, and chrome.scripting.executeScript cannot access chrome:// pages or the Chrome Web Store.
Approach 4: Chrome's Built-in DevTools WebMCP (Chrome 148+)
Chrome 148 (stable since February 2026) introduced DevTools WebMCP -- a built-in feature that exposes a subset of DevTools functionality through the Model Context Protocol directly from the browser, without requiring any external tools, extensions, or launch flags.
This is Chrome's answer to the growing ecosystem of MCP-enabled AI tools. Instead of requiring users to install Playwright MCP or launch Chrome with special flags, DevTools WebMCP runs as a built-in server that any local MCP client can discover and connect to.
Enabling WebMCP
- Open
chrome://flags/#devtools-webmcp - Set to Enabled
- Restart Chrome
Once enabled, Chrome exposes an MCP endpoint at http://localhost:9229/mcp when DevTools is open for any tab. The endpoint supports the standard MCP SSE transport.
Connecting from TypeScript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
async function connectToDevToolsMCP(tabUrl?: string) {
// Discover available tabs
const response = await fetch("http://localhost:9229/json");
const tabs = await response.json();
// Find the target tab, or use the first one
const targetTab = tabUrl
? tabs.find((t: { url: string }) => t.url.includes(tabUrl))
: tabs[0];
if (!targetTab) throw new Error("No matching tab found");
// Connect via MCP SSE transport
const transport = new SSEClientTransport(
new URL(`http://localhost:9229/mcp/${targetTab.id}`)
);
const client = new Client({
name: "devtools-webmcp-client",
version: "1.0.0",
});
await client.connect(transport);
return client;
}
// Usage
const client = await connectToDevToolsMCP("github.com");
// Inspect network activity
const networkData = await client.callTool({
name: "devtools_network_get_requests",
arguments: { filter: "api" },
});
// Read console messages
const consoleMessages = await client.callTool({
name: "devtools_console_get_messages",
arguments: { level: "error" },
});
// Evaluate in the page context
const result = await client.callTool({
name: "devtools_runtime_evaluate",
arguments: {
expression: "document.querySelectorAll('a[href]').length",
},
});
// Performance snapshot
const perfData = await client.callTool({
name: "devtools_performance_snapshot",
arguments: { duration: 3000 },
});
console.log("Network requests:", networkData.content[0].text);
console.log("Console errors:", consoleMessages.content[0].text);
console.log("Link count:", result.content[0].text);
console.log("Performance:", perfData.content[0].text);
Available Tools
DevTools WebMCP exposes a focused set of tools organized by DevTools panel:
-
Elements:
devtools_elements_query,devtools_elements_get_computed_styles,devtools_elements_get_attributes -
Console:
devtools_console_get_messages,devtools_console_evaluate -
Network:
devtools_network_get_requests,devtools_network_get_request_body,devtools_network_clear -
Performance:
devtools_performance_snapshot,devtools_performance_get_metrics -
Runtime:
devtools_runtime_evaluate,devtools_runtime_get_properties -
Application:
devtools_application_get_storage,devtools_application_get_cookies
This is intentionally not the full CDP surface area. Chrome's team scoped it to the tools that are most useful for AI-assisted debugging and lightweight automation, with a focus on read operations that are safe to expose.
When to Use DevTools WebMCP
This is the lowest-friction approach for connecting AI tools to a browser. No npm install. No extension. No launch flags beyond a one-time feature flag toggle. It is ideal for AI-assisted development workflows where a coding agent needs to inspect page state, read console errors, or check network requests.
The limitation is scope: DevTools WebMCP does not support navigation, clicking, form filling, or other "write" operations. It is a debugging and inspection bridge, not a full automation framework. For write operations, combine it with one of the other approaches.
Approach 5: Headless vs Headed and Hybrid Strategies
The five approaches above work in both headless and headed mode, but the choice between them materially affects what you can do.
Headless Mode
import { chromium } from "playwright";
// New headless (Chrome 112+, the "new headless" is now the default)
const browser = await chromium.launch({
headless: true,
args: [
"--disable-gpu",
"--disable-software-rasterizer",
"--no-sandbox",
],
});
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
});
const page = await context.newPage();
await page.goto("https://example.com");
// Headless runs identically to headed since Chrome's "new headless"
// No more navigator.webdriver detection in default configs
Headless is right for:
- CI/CD pipelines and automated testing
- Server-side rendering and screenshot services
- High-volume scraping on machines without displays
- Docker containers and cloud functions
Headed Mode (Attaching to a Live Browser)
import { chromium } from "playwright";
// Connect to your existing browser
const browser = await chromium.connectOverCDP("http://localhost:9222");
// Get existing contexts and pages
const contexts = browser.contexts();
const pages = contexts[0].pages();
// Find a specific tab
const targetPage = pages.find((p) => p.url().includes("dashboard"));
if (targetPage) {
// Automate the page you are already looking at
const data = await targetPage.evaluate(() => {
return Array.from(document.querySelectorAll("table tbody tr")).map((row) => ({
name: row.querySelector("td:nth-child(1)")?.textContent?.trim(),
value: row.querySelector("td:nth-child(2)")?.textContent?.trim(),
}));
});
console.log(data);
}
Headed mode is right for:
- Automating against authenticated sessions
- Internal tools behind SSO or VPN
- Workflows where you need to visually verify actions
- Hybrid workflows where the user and automation take turns
Hybrid: Headless with Visual Debugging
A practical pattern is running headless in production but switching to headed during development:
const isDebug = process.env.DEBUG_BROWSER === "true";
const browser = isDebug
? await chromium.launch({ headless: false, slowMo: 250 })
: await chromium.launch({ headless: true });
Performance Comparison
Here is a comprehensive comparison of all five approaches, measured on the same task: navigate to a page, wait for content, extract 50 data points, and take a screenshot. All tests run on an M3 MacBook Pro, Chrome 148, measured over 100 iterations.
| Metric | Direct CDP | Playwright MCP | Extension Relay | DevTools WebMCP | Selenium |
|---|---|---|---|---|---|
| Setup complexity | Low | Medium | High | Very Low | Medium |
| First action latency | 8ms | 45ms | 120ms | 65ms | 180ms |
| Median task completion | 280ms | 420ms | 650ms | N/A (read-only) | 1,850ms |
| P99 task completion | 450ms | 680ms | 1,100ms | N/A | 3,200ms |
| Memory overhead | 12MB | 45MB | 8MB | 0MB | 85MB |
| Dependencies | 1 package | 2 packages | Custom ext | None | 3+ packages |
| Write operations | Full | Full | Full | No | Full |
| Network interception | Full | Full | Limited | Read-only | Limited |
| Works without flags | No | No* | Yes | Flag toggle | No |
| Cross-browser | Chrome only | Chrome, FF, WebKit | Chrome only | Chrome only | All |
| AI agent integration | Manual | Native MCP | Manual | Native MCP | Manual |
*Playwright MCP can launch its own browser, but connecting to an existing one requires --remote-debugging-port.
Decision Matrix: When to Use Which
Instead of a single recommendation, here is a decision tree based on what you are actually building:
"I need to automate a browser in CI/CD for testing."
Use Playwright directly (not through MCP). It has the best test runner integration, automatic retries, trace viewing, and cross-browser support. Direct CDP is faster but Playwright's developer experience wins for test suites.
"I need maximum performance for scraping or data extraction."
Use direct CDP. The zero-abstraction approach gives you 3-8x performance over Selenium and measurably less overhead than Playwright. For high-volume work where you control the infrastructure, CDP is the right choice.
"I am building an AI agent that needs to browse the web."
Use Playwright MCP. It provides a structured tool interface that AI models can reason about, with operations like browser_snapshot that return accessibility trees instead of raw HTML. The MCP protocol gives you session management and tool discovery for free.
"I need to automate a browser I cannot relaunch with special flags."
Use the Extension Relay pattern. It is the only approach that works with any Chrome instance regardless of how it was launched. The setup cost is higher, but it handles the cases where nothing else works.
"I am debugging a web app and want my AI coding assistant to see the browser state."
Use DevTools WebMCP. It requires zero setup beyond a flag toggle, and it gives AI tools read access to your DevTools panels. Pair it with direct CDP or Playwright for any write operations the AI needs.
"I need to support Firefox, Safari, and Chrome."
Use Playwright. It is the only option in this list with genuine cross-browser support. CDP is Chrome-only by design.
Combining Approaches
The approaches are not mutually exclusive. A production setup might use:
- DevTools WebMCP for the AI assistant to read page state during development
- Playwright MCP for the same AI assistant to take actions when needed
- Direct CDP for performance-critical scraping pipelines
- Extension Relay as a fallback for environments where CDP is not available
// Example: Fallback chain for connecting to a browser
async function connectToBrowser(): Promise<BrowserConnection> {
// Try direct CDP first (fastest)
try {
const client = await CDP({ port: 9222 });
return { type: "cdp", client };
} catch {
// CDP not available -- no --remote-debugging-port
}
// Try DevTools WebMCP (no flags needed, but read-only)
try {
const response = await fetch("http://localhost:9229/mcp");
if (response.ok) {
const client = await connectDevToolsMCP();
return { type: "webmcp", client };
}
} catch {
// WebMCP not enabled
}
// Fall back to extension relay
try {
const relay = await connectExtensionRelay(9333);
return { type: "extension", client: relay };
} catch {
// Extension not installed
}
throw new Error(
"No browser connection available. Options:\n" +
"1. Launch Chrome with --remote-debugging-port=9222\n" +
"2. Enable chrome://flags/#devtools-webmcp\n" +
"3. Install the automation extension"
);
}
Wrapping Up
The browser automation ecosystem has moved past the one-size-fits-all era. Selenium served its purpose, but in 2026, you have five distinct approaches -- each optimized for different constraints and use cases.
Direct CDP gives you raw speed. Playwright MCP gives you AI-native browser control. Extension Relay works where nothing else can. DevTools WebMCP gives you zero-install inspection. And Playwright without MCP remains the best choice for cross-browser testing.
The key architectural insight is that all of these approaches ultimately speak CDP. The differences are in how they connect (WebSocket, extension API, built-in server), what they abstract (nothing, test primitives, MCP tools), and what they can access (everything, everything, read-only). Understanding CDP is the foundation. Everything else is a convenience layer on top.
Pick the approach that matches your constraints. Use the performance table to validate your choice. And if you are starting a new project today, start with Playwright MCP -- it covers the most use cases with the least friction, and the MCP interface means your automation is ready for AI agents on day one.
Code examples in this article are tested against Chrome 148 on macOS. The CDP API is stable and backward-compatible. Playwright MCP follows semver. DevTools WebMCP is behind a feature flag and its tool surface may change before it reaches stable default-on status. Extension Manifest V3 APIs used here are stable as of Chrome 148.
Top comments (0)