๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’๐Ÿ”’

Reverse-Engineering Claude's Browser Extension

Sebastian Sosa ยท April 2026 ยท noemica.io

The official Claude in Chrome extension gives Claude Code full browser automation: screenshots, clicking, typing, JS eval, the whole thing. It also blocks 58 domains across 11 categories. So I reverse-engineered it from scratch. The result is Open Claude in Chrome, 2,200 lines of unrestricted reimplementation, with six production bugs I didn't see coming.

This isn't a product announcement. It's a build story about what happens when you reverse-engineer a Chromium extension's MCP protocol, reimplement it without restrictions, and then discover why those restrictions might have been the easy part.

I was already building browser automation tooling for Noemica, an AI testing platform I'm working on. Specifically, a simpler open-source MCP that could run, play, and write actions against a browser container. When I saw what Claude in Chrome offered, and what it restricted, the path was obvious.

The Blocklist#

I was using Claude Code for browser-based workflows (research, outreach, testing) and kept getting blocked. Not by firewalls or CAPTCHAs. By the extension itself.

The official Claude in Chrome extension has a hardcoded domain blocklist. I expected maybe a dozen sites. Social media, adult content. Reasonable. Here's what's actually blocked:

CategoryBlocked
BankingChase, BofA, Wells Fargo, Citibank
InvestingSchwab, Fidelity, Robinhood, E-Trade, Wealthfront, Betterment
PaymentsPayPal, Venmo, Cash App, Zelle, Stripe, Square, Wise, Western Union, MoneyGram, Adyen, Checkout.com
BNPLKlarna, Affirm, Afterpay
NeobanksSoFi, Chime, Mercury, Brex, Ramp
CryptoCoinbase, Binance, Kraken, MetaMask
GamblingDraftKings, FanDuel, Bet365, Bovada, PokerStars, BetMGM, Caesars
DatingTinder, Bumble, Hinge, Match, OKCupid
AdultPornhub, XVideos, XNXX
News/MediaNYT, WSJ, Barronโ€™s, MarketWatch, Bloomberg, Reuters, Economist, Wired, Vogue
SocialReddit

The pattern breaks down into a few theories. Financial sites (banking, investing, payments, BNPL, neobanks, crypto) and gambling: prevent automated transactions and fraud risk. Dating apps: prevent automated catfishing and impersonation. Adult: content safety. News/media is the interesting one. NYT, WSJ, Bloomberg, Reuters, and Economist have been aggressive about AI scraping lawsuits. Wired and Vogue share a Condรฉ Nast parent with Vanity Fair and GQ, which aren't blocked, suggesting per-site legal requests, not blanket publisher bans. And Reddit is the only social platform blocked out of 40+ tested, likely because of their $60M Google data deal and aggressive pursuit of scrapers.

The extension also only supports Chrome and Edge. I use Brave. So even without the blocklist, I couldn't use it.

I decided to reverse-engineer the whole thing.

What I Was Replacing#

The official extension exposes 18 MCP tools to Claude Code. That's the entire surface area: navigate to a URL, take a screenshot, click at coordinates, type text, press keys, scroll, read the page's accessibility tree, execute JavaScript, monitor console messages and network requests, manage tabs, resize the window, and a few more.

I didn't have the source code. I had the tool schemas (the names, parameters, and descriptions of all 18 tools) and I could observe the extension's behavior by using it. That's enough.

There's a subtlety here that matters for why the reimplementation works so well. Claude was almost certainly trained to use these exact tool schemas. When Claude Code sees a tool called navigate with parameters url and tabId, it doesn't need to figure out what to do. It already knows, the same way it knows how to call Read or Edit. By preserving the exact same tool names, parameter signatures, and descriptions from the official extension, the reimplementation gets Claude's trained behavior for free. The model doesn't know or care that it's talking to a different extension. It just sees the same interface it was trained on.

The architecture turned out to be five processes deep:

Claude Code <- stdio -> MCP Server <- TCP :18765 -> Native Host <- native msg -> Extension -> Browser JSON-RPC/MCP newline-delimited JSON 4-byte LE + JSON CDP / chrome.*

Every tool call traverses the full chain: Claude Code sends MCP over stdio to a Node.js server, which forwards over TCP to a native messaging host, which translates to Chrome's native messaging protocol (4-byte little-endian length prefix + JSON), which delivers to the extension's service worker, which executes via CDP or chrome.* APIs.

The response takes the same path back. Five processes, four protocol translations, for every single click.

Why three hops instead of one? Chromium extensions can't listen on stdio. MCP servers must use stdio. The native messaging host is the bridge: the browser launches it as a subprocess with stdin/stdout wired to the extension, and it opens a TCP socket to the MCP server. There's no shortcut.

The Build#

Seven commits. Here's the shape of it:

6Source files
2,200Lines of JS
18MCP tools
7Commits
6Prod bugs

The first commit had all 18 tools working. 17 of them passed the integration test on the first run. The other 6 commits were all about the things that broke in production that I never would have caught in a test harness.

Those 6 commits are the interesting part.

Bug 1: The Service Worker Dies Mid-Operation#

Manifest V3 service workers are ephemeral. The Chromium browser can kill them after roughly 30 seconds of inactivity. This is by design. It's also completely hostile to an extension that needs a persistent native messaging connection.

The navigate tool was the first victim. It waited up to 30 seconds for a page to finish loading, plenty of time for the browser to decide the service worker was idle and terminate it. When the worker dies, the native messaging port is destroyed. The entire chain collapses.

Symptom: the first navigate call always fails. The second one works, because the reconnect logic kicks in and the service worker restarts.

The fix was two things. First, reduce the page-load timeout from 30 seconds to 10. If the page isn't done, return "(still loading)" and let the caller take a screenshot later to check. Second, a keepalive alarm:

chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === "keepalive") {
    if (!nativePort) connectNativeHost();
  }
});

Every 24 seconds, the alarm fires. If the native messaging connection dropped, it reconnects. This doesn't prevent the service worker from dying (nothing can) but it ensures recovery happens fast enough that the user barely notices.

Bug 2: Retina Screenshots Blow Up the API#

Every computer tool action (click, type, scroll, hover) automatically returns a screenshot. This is what lets Claude see what happened after each action. Good design. Fatal at scale.

On a Retina display, a single PNG screenshot is 5-10MB as base64. After 3 tool calls, the conversation payload exceeds Claude Code's 20MB API request limit. The session becomes completely unresponsive.

The fix was aggressive:

// Always use JPEG โ€” PNG on retina displays can be 5-10MB
const result = await cdp(tabId, "Page.captureScreenshot", {
  format: "jpeg",
  quality: 55,
  optimizeForSpeed: true,
});
let base64 = result.data;

// If still too large (>500KB base64), reduce quality further
if (base64.length > 500000) {
  const smaller = await cdp(tabId, "Page.captureScreenshot", {
    format: "jpeg",
    quality: 30,
    optimizeForSpeed: true,
  });
  base64 = smaller.data;
}

JPEG-only, quality 55, with a fallback to quality 30 if the image is still over 500KB. Typical screenshot went from ~5MB to ~200KB. That's the difference between 3 tool calls and 100.

ParameterBeforeAfter
FormatPNGJPEG always
Quality80 (PNG ignores this)55, fallback to 30
Typical size~5 MB~200 KB
Tool calls before 20MB limit~3~100

I didn't think about this at all during the initial build. It only surfaced when I tried to do an actual multi-step workflow: navigate to a page, click a few things, read some text. Three actions in and the whole thing locked up. The kind of bug that never appears in a โ€œdoes the tool work?โ€ test but kills every real usage.

Bug 3: Reddit's Search Bar Doesn't Exist#

The form_input tool sets the value of form elements. It finds the element by ref ID, sets the value using the native setter, and dispatches input/change events. Simple.

It failed on Reddit with Uncaught TypeError: Illegal invocation.

Reddit's search bar isn't an <input>. It's a custom web component: <reddit-search-large>. The actual input element is buried inside its shadow DOM. When you call HTMLInputElement.prototype.value.set on the custom element, you get Illegal invocation because it's not an HTMLInputElement. It just looks like one.

The fix traverses shadow roots to find the real input:

function findInputInside(el) {
  const tag = el.tagName.toLowerCase();
  if (["input", "textarea", "select"].includes(tag)) return el;

  // Check shadow DOM first
  const root = el.shadowRoot || el;
  const inner = root.querySelector("input, textarea, select");
  if (inner) return inner;

  // Recurse into shadow roots of children
  for (const child of root.querySelectorAll("*")) {
    if (child.shadowRoot) {
      const deep = child.shadowRoot.querySelector(
        "input, textarea, select"
      );
      if (deep) return deep;
    }
  }
  return null;
}

Two levels deep into shadow roots, then dispatch events with composed: true so they cross shadow DOM boundaries. This isn't documented in any โ€œhow to build a Chrome extensionโ€ guide. You find it when your extension works on every site except the one that uses web components.

Bug 4: The State Amnesia#

The extension keeps track of which tabs belong to the โ€œMCPโ€ tab group using an in-memory Set. Every tool call checks whether the target tab is in this group before executing. Reasonable.

When the service worker restarts, and remember, the browser can kill it at any time, that Set is empty. Every subsequent tool call returns "Tab X is not in the MCP group." The tabs are still there. The group is still there. The extension just forgot about them.

Two fixes. First, isInGroup() now queries the live chrome.tabs API instead of checking the in-memory set:

async function isInGroup(tabId) {
  // Always check live state โ€” in-memory tabGroupTabs
  // can be stale after service worker restart
  const tab = await chrome.tabs.get(tabId);
  if (tab.groupId !== -1) {
    // Recover tabGroupId if we lost it
    if (tabGroupId === null) {
      tabGroupId = tab.groupId;
    }
    return true;
  }
  return false;
}

Second, on startup the extension queries for any existing โ€œMCPโ€ group and rebuilds its state:

async function recoverTabGroupState() {
  const groups = await chrome.tabGroups.query({ title: "MCP" });
  if (groups.length > 0) {
    tabGroupId = groups[0].id;
    const tabs = await chrome.tabs.query({ groupId: tabGroupId });
    tabGroupTabs = new Set(tabs.map(t => t.id));
  }
}

recoverTabGroupState();
connectNativeHost();

The lesson: in a Manifest V3 extension, in-memory state is a lie. Anything you store in a variable can vanish without warning. Every read should go to the API. Every startup should assume amnesia.

Bug 5: The Profile Wars#

Chromium browser profiles each run their own extension instance. If the extension is loaded in Profile A and Profile B, both call connectNative(), both spawn separate native host processes, and both try to connect to TCP port 18765.

Originally, the MCP server would accept the latest connection and drop the previous one. The result was tabs appearing and disappearing randomly as profiles fought for control. One profile would create a tab, the other would steal the connection, the first profile's tool call would fail, it would reconnect, steal it back. Chaos.

The fix was a single-connection lock:

if (nativeHostSocket && !nativeHostSocket.destroyed) {
  socket.end(JSON.stringify({
    type: "error",
    error: "Another browser profile is already connected. " +
           "Disable the extension in other profiles."
  }) + "\n");
  socket.destroy();
  return;
}

First connection wins. Others get a clear error message. There's also a pidfile (/tmp/unblocked-chrome-mcp-18765.pid) so that new Claude Code sessions can kill stale MCP servers from previous sessions, and a 60-attempt zombie detector in the native host that exits if the MCP server disappears for 30 seconds.

Bug 6: The Invisible Coordinate Space#

After fixing the first five bugs, the extension worked. Tools executed. Screenshots came back. Pages loaded. But Claude couldn't click on anything accurately. It would look at a screenshot, estimate coordinates for a button, and miss. Not by a lot, just enough to hit the wrong element every time.

I spent hours debugging coordinate math before trying the obvious thing: running the official Claude in Chrome extension and ours side-by-side on the exact same page. The official produced a 1080x746 screenshot. Ours produced a 3024x1630 screenshot. Same machine. Same browser. Same tab dimensions.

A quick window.devicePixelRatio check confirmed it. The official extension's tab reported devicePixelRatio: 1. Ours reported devicePixelRatio: 2. The official extension was lying to the browser about the display.

Here's what it does: when attaching the debugger to a tab, the official extension calls Emulation.setDeviceMetricsOverride via CDP, forcing deviceScaleFactor to 1. This normalizes the entire coordinate space. Screenshots, click targets, and the accessibility tree all operate at the same logical pixel scale. Without it, Retina displays double every dimension: the screenshot is 3024px wide, but the input dispatch coordinate space is still 1512px wide. Claude estimates coordinates from what it sees in the screenshot, sends them as click targets, and misses everything by a factor of two.

The fix was one CDP call in ensureAttached():

// Force devicePixelRatio to 1 โ€” normalizes coordinate space
// so screenshots, clicks, and accessibility tree all align
const { width, height } = await cdp(tabId,
  "Browser.getWindowBounds", {});
await cdp(tabId, "Emulation.setDeviceMetricsOverride", {
  width: width,
  height: height,
  deviceScaleFactor: 1,
  mobile: false,
});

One line that changes everything. Without it, the extension is functionally broken on every Retina display, which is every MacBook shipped in the last decade. But it passes every test, because the screenshots look fine, the tools return success, and the coordinates are valid numbers. They're just the wrong numbers.

The same side-by-side session revealed a second problem: the official extension doesn't return screenshots on click, type, key, or hover actions. It only returns screenshots on explicit screenshot and scroll calls. Our extension was returning a screenshot after every single action, burning through context roughly 10x faster. A workflow that should have lasted 100 tool calls was hitting the context limit after 10.

Both discoveries came from the same debugging session. I wouldn't have found either one by reading code or writing tests. The only thing that worked was running both extensions simultaneously and comparing every response field by field.

ยท ยท ยท

What I Actually Learned#

The extension works. All 18 tools, no domain restrictions, any Chromium browser. I use it daily. But the build taught me something about how production software differs from working software.

The initial build was 90% of the code and 10% of the work. 17 of 18 tools passed on the first integration test. I thought I was done. Then I tried to actually use it: navigate to a real page, click through a real workflow, use it for more than 3 tool calls in a row. Six distinct production bugs. Every one of them was invisible to a โ€œdoes it work?โ€ test and fatal to a โ€œcan I use this?โ€ session.

Manifest V3 is actively hostile to persistent extensions. The service worker lifecycle is designed for extensions that respond to events and go quiet. An extension that maintains a persistent native messaging connection, tracks state across tool calls, and buffers console and network events is fighting the platform every step of the way. The keepalive alarm, the state recovery, the auto-reconnect, the live API queries instead of in-memory reads: all of it exists because MV3 assumes your extension doesn't need to be running.

The web is messier than the spec. Shadow DOM broke my form input handler. Retina displays broke my screenshot pipeline. Multiple browser profiles broke my TCP connection model. None of these are exotic edge cases. They're the default state of a modern browsing environment. Any extension that assumes <input> elements are <input> elements, that screenshots are a reasonable size, and that only one profile exists at a time is going to have a bad time.

Five processes is too many. The architecture works, and it's the only architecture that can work given the constraints. But five processes and four protocol translations for a single mouse click is a complexity surface that generates bugs at every boundary. The multi-profile fight was a TCP boundary bug. The service worker death was a native messaging boundary bug. The stale server was a stdio boundary bug. Each hop is a failure mode.

The Repo#

It's open source and MIT licensed: Open Claude in Chrome

6 files. ~2,200 lines. Works in any Chromium browser (Chrome, Brave, Edge, Arc, Opera, Vivaldi). No domain blocklist. All 18 MCP tools. Drop-in replacement for the official extension.

If you're hitting the domain restrictions or using a non-Chrome browser, give it a shot. If you find bugs, and you probably will, because the Manifest V3 service worker lifecycle is a wellspring of surprise, PRs are welcome.

I build tools for testing AI agents against realistic user behavior. If you're shipping agents and want to know what breaks before your users do, I'd love to chat.