custom-chrome-dev-mcp
by HabaAndrei
README.md
# Custom Chrome Dev MCP
A **local-only** MCP (Model Context Protocol) server that lets an MCP client - Claude
Code, or anything else that speaks MCP - drive **your real Chrome browser** the way a
person would. No telemetry, no third-party services, no cloud: everything runs on your
machine behind a shared token.
It exposes **45 tools** across navigation, tabs, perception, interaction, trusted
input, observability, and capture.
---
## Where this came from
This project is **inspired by Chrome's official browser MCP** - the
[**Chrome DevTools MCP**](https://github.com/ChromeDevTools/chrome-devtools-mcp)
server published by the Chrome DevTools team, which first made the case that an AI
agent should drive a browser through the DevTools Protocol rather than through
scraped HTML.
**We are replicating and imitating that idea, not shipping it.** What we borrowed:
- **The premise** - expose the browser to an agent as a set of MCP tools.
- **Accessibility-first perception** - hand the model a compact a11y outline with
stable element refs instead of a wall of raw HTML.
- **The Chrome DevTools Protocol as the input layer** - real, trusted events instead
of synthetic ones a page can spot and ignore.
Where this project **deliberately diverges**:
| | Chrome DevTools MCP | Custom Chrome Dev MCP |
| --- | --- | --- |
| Browser | **By default** launches its own Chrome with a dedicated user-data-dir; can also attach to a running instance via `--browser-url` | **Only ever** drives the Chrome you already have open |
| Attachment | Connects to the browser over the DevTools Protocol endpoint | A **Chrome extension living inside the browser**, pointed at whatever tab you choose |
| Primary goal | Debugging, inspecting, and profiling a page | **Behaving like a human using that page** |
That last row is the whole point of this repo. Chrome DevTools MCP is a debugging
tool that happens to drive a browser; this is an imitation-of-a-person tool that
happens to be useful for debugging.
> ⚠️ Not affiliated with, endorsed by, or supported by Google or the Chrome team.
> This is an independent reimplementation built to learn from and imitate their
> design. Use the official server if you want the supported thing.
---
## It deliberately keeps the styling of a human
Most browser automation is trivially detectable: synthetic events with
`isTrusted=false`, focus that never really moves, text that appears in a field all at
once, a pristine automation profile with no history. Every one of those is a signal.
This project tries to remove those signals:
- **Your real profile.** Actions run in the Chrome you already use - your cookies,
logins, extensions, and history. Nothing to fingerprint as "fresh automation".
- **Trusted input.** `realClick`, `realType`, `press`, `hover`, and `drag` dispatch
through the DevTools Protocol, so the page receives events with `isTrusted=true` -
the same flag a physical mouse and keyboard produce.
- **Genuine focus.** Clicking to focus a field really moves focus, in order, rather
than assigning `.value` behind the page's back.
- **Real keystrokes.** `press` emits proper `rawKeyDown` / `char` / `keyUp` sequences
with correct key codes and modifiers, not a single synthetic `input` event.
- **Read-back verification.** `fill` confirms the field actually holds the text, so
the agent notices when a page silently rejected the input - as a person would.
The goal: a page should behave for the agent exactly as it behaves for someone sitting
at the keyboard.
Fast synthetic tools (`click`, `type`) are still there - they're quicker and work on
most sites. When a page ignores them, reach for the trusted equivalents.
---
## How it works
One transport. The MCP client talks to the server over stdio; the server relays to a
Chrome extension over a local WebSocket owned by a small long-lived **hub** process.
```
MCP client 1 (Claude) <-stdio-> bin/custom-chrome-dev-mcp.js ─┐
MCP client 2 (Claude) <-stdio-> bin/custom-chrome-dev-mcp.js ─┼─ src/hub.js (127.0.0.1:9876)
MCP client N (Claude) <-stdio-> bin/custom-chrome-dev-mcp.js ─┘ │
│ WebSocket
▼
Chrome extension -> active tab
```
**Why a separate hub process.** Only one process can own port `9876`, but you may
have several Claude sessions open and all of them may want the browser. So the socket
lives in `src/hub.js` rather than inside any one session. Each session connects to the
hub as `role:"mcp"`, the extension connects as `role:"extension"`, and the hub
multiplexes between them. The first session to start spawns the hub **detached**, so
it outlives that session; later sessions find it already listening.
Inside the extension there are three layers:
1. **Walker** (`page/walker.js`) - injected into the page's ISOLATED world. Owns
element resolution, the stable `eN` ref map, and the fast synthetic DOM ops.
2. **CDP** (`cdp/`) - `chrome.debugger` for trusted input, page-context `evaluate`,
full-page screenshots, and the console/network buffers.
3. **Recording** (`recording/`) - CDP screencast frames encoded to `.webm` by a
`MediaRecorder` in an offscreen document.
> 🔒 The extension authenticates to the hub with a shared token (`AUTH_TOKEN`,
> identical in `src/config.js` and `extension/src/config.js`). The hub drops any peer
> that presents a different value.
---
## Prerequisites
| | Requirement | Check |
| --- | --- | --- |
| **Node.js** | 18 or newer (developed on 22) | `node --version` |
| **Chrome** | Google Chrome or Chromium, any recent version | `chrome://version` |
| **An MCP client** | Claude Code, or anything else that speaks MCP over stdio | `claude --version` |
No global installs, no build step, no service to sign up for. Two runtime dependencies
(`@modelcontextprotocol/sdk` and `ws`) and everything stays on `127.0.0.1`.
---
## Local setup
Four steps, then a verification pass. Budget five minutes.
### 1. Clone and install
```sh
git clone <your-fork-url> custom-chrome-dev-mcp
cd custom-chrome-dev-mcp
npm install
```
Confirm the tree is healthy before wiring anything to Chrome - the offline lane needs
no browser and takes under a second:
```sh
npm test
```
You want `22 passed`. If that fails, fix it before continuing; nothing downstream will
work.
### 2. Load the extension into Chrome
1. Open `chrome://extensions`.
2. Turn on **Developer mode** (top-right toggle).
3. Click **Load unpacked** and select the **`extension/`** folder - the folder itself,
not `manifest.json` inside it.
4. **Custom-chrome-dev-mcp** appears in the list.
> ⚠️ **Load it into the Chrome profile you actually browse in.** Chrome keeps
> extensions per profile, so an extension loaded into "Profile 4" is invisible to the
> window running under "Default". If tools later report no tabs, or the hub never logs
> `extension connected`, this is the first thing to check. `chrome://version` shows the
> active **Profile Path**.
The extension ID is pinned by the public `key` in `extension/manifest.json`, so it is
identical on every machine - nothing to copy between setups.
### 3. Register the MCP server with your client
**Use the CLI** - substitute the absolute path where you cloned the repo (`pwd` in the
project root prints it):
```sh
claude mcp add -s user custom-chrome-dev-mcp -- node /ABSOLUTE/PATH/TO/custom-chrome-dev-mcp/bin/custom-chrome-dev-mcp.js
```
- `-s user` registers it for **all** your projects; `-s local` limits it to this one.
- Register **`bin/custom-chrome-dev-mcp.js`** - that file is the entry point. Pointing
at `src/server.js` will not work.
- The path must be **absolute**. A relative path resolves against whatever directory
the client happened to launch from.
- Confirm with `claude mcp list` - you want a `✔ Connected` next to it.
> ⚠️ **Do not hand-edit `~/.claude.json`.** It is large, and one misplaced comma breaks
> Claude Code entirely. The command above edits it safely.
<details>
<summary>Alternative - manual JSON (only if you can't use the CLI)</summary>
Add the server under `mcpServers`:
```json
{
"mcpServers": {
"custom-chrome-dev-mcp": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/custom-chrome-dev-mcp/bin/custom-chrome-dev-mcp.js"]
}
}
}
```
</details>
### 4. Restart the MCP client
MCP clients enumerate tools once, at startup - a server registered mid-session is
invisible until you restart. Restart Claude, and the 45 tools appear.
On restart the client launches the server, which spawns `src/hub.js` if nothing is
already listening on `127.0.0.1:9876`.
### 5. Verify all three links in the chain
The stack is *client → server → hub → extension → tab*. Check it end to end rather
than guessing which link is down.
```sh
# The hub is up, and the extension found it:
tail -f "$TMPDIR/custom-chrome-dev-mcp-hub.log"
# [hub] listening on 127.0.0.1:9876
# [hub] extension connected <- this line is the handshake succeeding
# Who owns the port (should be src/hub.js from THIS repo):
lsof -nP -iTCP:9876 -sTCP:LISTEN
```
Then ask your client for **`listTabs`**. A JSON array of your open tabs means every
link works. Follow it with **`screenshot`** - a PNG lands in `~/Downloads` *and* comes
back inline.
For the extension's own console: `chrome://extensions` → **Custom-chrome-dev-mcp** →
**service worker** → **Inspect**. That is where extension-side errors surface; they
never reach the MCP client.
### 6. Change the shared token before real use
`AUTH_TOKEN` ships with a default value, defined identically in **`src/config.js`** and
**`extension/src/config.js`**. It is the only thing stopping another process on your
machine from driving your logged-in browser. Pick your own value, change it in **both**
files (an offline test asserts they match), and reload the extension.
---
## After you change code
The two halves reload differently, and getting this wrong wastes more time than
anything else in the project:
| You edited | To pick it up |
| --- | --- |
| Anything under **`extension/`** | Click **reload ↻** on the extension in `chrome://extensions`. Chrome keeps running the previously loaded build until you do. |
| Anything under **`src/`** | Restart the MCP client. The server process is long-lived and holds the old tool schemas. |
| **`src/hub.js`** | `pkill -f src/hub.js` - the next tool call respawns it. |
---
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `claude mcp list` shows `✘ Failed to connect` | Wrong path, or not the `bin/` entry point | Re-register with the absolute path to `bin/custom-chrome-dev-mcp.js` |
| Tools missing from the client entirely | Registered mid-session | Restart the MCP client |
| Hub log never says `extension connected` | Extension not loaded, loaded in a different Chrome profile, or `AUTH_TOKEN` differs between the two `config.js` files | Check `chrome://version` → Profile Path; confirm both tokens match |
| A tool call hangs, then times out | The service worker died, or an extension-side exception | Open the service worker console; click **reload ↻** |
| Port `9876` owned by an unexpected process | A hub from another clone of this project is squatting the port | `lsof -nP -iTCP:9876 -sTCP:LISTEN`, then kill that PID |
| An edit to `extension/` "did nothing" | Chrome is still running the old build | Click **reload ↻** |
| `URL is banlisted` | `BANLIST` in `extension/src/config.js` blocks that host | Edit the list - it ships with placeholder entries |
| `refusing to act: … does not contain expectUrl` | The `expectUrl` guard fired, correctly | Drop the guard, or point it at the real URL |
| Screenshot path rejected | Writes are confined to the capture directory | Use a filename or a path **inside** it |
| A tool targets the wrong tab | A background tab stole focus | Pin the working tab with `useTab` |
---
## Configuration
Both are optional environment variables read at startup by `src/config.js`.
| Variable | Default | What it does |
| --- | --- | --- |
| `CUSTOM_CHROME_DEV_MCP_CAPTURE_DIR` | `~/Downloads` | The **only** directory screenshots and recordings may be written to. |
| `CUSTOM_CHROME_DEV_MCP_WS_PORT` | `9876` | Hub port. Change it in `extension/src/config.js` too, or they won't find each other. |
Also worth changing for real use: **`AUTH_TOKEN`**, defined identically in
`src/config.js` and `extension/src/config.js`. Pick your own value - it is what stops
another local process from driving your browser.
---
## Available tools (45)
Elements are targeted three ways: **`selector`** (CSS), **`ref`** (a stable `eN` id
from `snapshotA11y`), or **`name`** (accessible name, e.g. a button's label). "target"
below means any one of those three.
**Universal params**, accepted by every tool:
- **`tabId`** - act on a specific tab instead of the ambient active one.
- **`frameId`** (from `listFrames`) - act inside a specific frame, including
cross-origin iframes the top document cannot script.
- **`expectUrl`** - a guard: refuse the action unless the tab's URL contains this
substring.
Pin a working tab for the whole session with **`useTab`** so a background tab (an
autoplaying video, a notification popup) can't steal focus and misdirect an action.
### Navigation
| Tool | Args | Description |
| --- | --- | --- |
| `navigate` | `url` | Point the tab at a URL (replaces the page). |
| `newtab` | `url` | Open a URL in a **new** foreground tab, leaving the current page intact. |
| `back` / `forward` | - | History back / forward. |
| `reload` | `hard?` | Reload, optionally bypassing the cache. |
| `getUrl` / `getTitle` | - | The tab's URL / title (works on internal pages too). |
| `waitForLoad` | `timeout?` | Block until the tab finishes loading. |
### Tabs & frames
| Tool | Args | Description |
| --- | --- | --- |
| `listTabs` | - | Every open tab across all windows (`id`, `title`, `url`, `active`, `pinned`). |
| `activateTab` | `tabId` | Focus a tab and its window. |
| `closeTab` | `tabId` | Close a tab by id. |
| `useTab` | `tabId?` | **Pin** the working tab so every later tool targets it regardless of OS focus. Omit `tabId` to pin the current one. |
| `unpinTab` | - | Release the pin; tools revert to the active tab. |
| `listFrames` | - | Every frame incl. cross-origin as `{frameId, parentFrameId, url, origin}`. |
### Perception
| Tool | Args | Description |
| --- | --- | --- |
| `snapshotA11y` | - | Compact accessibility outline of visible interactive elements as `role "name" ref=eN`. **Prefer this over `snapshot`.** Refs expire on navigation or re-snapshot. |
| `snapshot` | - | Raw `outerHTML` of `<body>`, truncated to 50k. Use when you need exact markup. |
| `getText` | target | `innerText` of one element, trimmed. |
| `getAttribute` | target, `attr` | An attribute, falling back to the live DOM property (`value`, `checked`, `href`). |
| `queryAll` | `selector`, `limit?` | text/href/value/visible for **every** match at once. |
| `viewport` | - | `devicePixelRatio`, CSS viewport, scroll offset - how you map screenshot px → CSS px. |
### Interaction - synthetic, fast
Untrusted events dispatched by the walker. Quick, and enough for most sites.
| Tool | Args | Description |
| --- | --- | --- |
| `click` | target | Bubbling `MouseEvent` click; also **focuses** the element; `.click()` fallback. Returns `{focused}`. |
| `type` | target, `text` | Set a field's value via the native setter (handles `<input>`, `<textarea>`, **and** contenteditable). Returns `{value}`. |
| `fill` | target, `text`, `verify?` | Focus + set + **read back**. Throws if the text didn't stick. **The reliable text-entry path** - prefer it over click-then-type. |
| `assert` | target, `text?`, `value?` | Verify text (substring) and/or exact value without a screenshot → `{ok, checks}`. |
| `scroll` | target?, `direction?`, `amount?` | Scroll an element into view, or the window (`top`/`bottom` jump to extremes). |
| `select` | target, `value?` / `label?` | Choose a `<select>` option by value or visible label. |
| `check` | target, `checked` | Set a checkbox/radio, clicking only if it isn't already there. |
| `submit` | target | `requestSubmit()` the owning form - for forms with no clickable button. |
| `waitForSelector` | target **or** `text`, `timeout?` | Poll until an element resolves **or** a text substring appears. |
### Trusted input & emulation - CDP
Real events with `isTrusted=true`. These attach `chrome.debugger`, which shows a
persistent yellow *"being debugged"* banner on the tab.
| Tool | Args | Description |
| --- | --- | --- |
| `realClick` | target / `x,y`, `button?`, `clickCount?` | **Trusted** click, including right-click and double-click. |
| `realType` | target?, `text` | Trusted text insertion, focusing the target first if given. |
| `press` | `keys`, target? | Trusted keys and combos: `"Enter"`, `"Tab"`, `"Meta+c"`, `["ArrowDown","Enter"]`. |
| `hover` | target / `x,y` | Move the real mouse over an element to fire `:hover` (reveals menus and tooltips). |
| `drag` | `from`, `to` | Trusted press-move-release drag & drop. |
| `uploadFile` | `selector`, `paths[]` | Set files on an `<input type=file>`, bypassing the OS picker. Absolute paths. |
| `setViewport` | `width`, `height`, `deviceScaleFactor?`, `mobile?`, `userAgent?` | Emulate a viewport / device for responsive checks. |
| `handleDialog` | `accept?`, `promptText?` | **Pre-arm** an answer for the next `alert`/`confirm`/`prompt`. Set it *before* the action that triggers the dialog. |
| `detach` | - | Detach the debugger and clear the banner. Re-attaches on the next CDP call. |
### Observability - CDP, buffered per tab
Capture starts when the debugger attaches, so **reload the page after the first CDP
call** if you want load-time activity.
| Tool | Args | Description |
| --- | --- | --- |
| `getConsole` | `level?`, `limit?`, `clear?` | Buffered console logs, warnings, errors, and uncaught exceptions. |
| `listNetworkRequests` | `urlContains?`, `status?`, `failedOnly?`, `limit?` | Buffered requests: method, url, status, type, timing. |
| `getNetworkRequest` | `requestId`, `includeBody?` | One request in full; `includeBody` also fetches the (truncated) response body. |
| `evaluate` | `expression` | Run JS in the page's **real** context via CDP - bypasses the content-script CSP that blocks `eval`. Awaits promises. Not available on `chrome://` pages. |
### Capture
Saved into the capture directory (`~/Downloads` by default - see
[Configuration](#configuration)).
| Tool | Args | Description |
| --- | --- | --- |
| `screenshot` | `path?`, `format?`, `tabId?` | Visible viewport as PNG/JPEG - **saved to disk and returned inline** with `{devicePixelRatio, cssViewport}`, so the model sees it in one call. |
| `fullPageScreenshot` | `path?`, `tabId?` | The **entire scrollable page** beyond the viewport, via CDP. |
| `record` | `action`, `path?`, `tabId?` | `start` / `stop` / `status` tab recording → `.webm`. Fully MCP-driven - **no toolbar click or user gesture needed**. Records the **tab**, not the desktop. |
`path` is a filename or a path **inside** the capture directory. Missing subfolders
are created; anything resolving outside the directory is refused.
---
## A first real run
Setup step 5 proves the wiring. This proves the interesting part - that a page sees a
person rather than a script. Point your client at any page and ask for:
1. `snapshotA11y` - the compact outline, with `eN` refs to target.
2. `realClick {ref:"e3"}` - a trusted click. The tab grows a yellow *"being debugged"*
banner; that is the CDP attach, and it is meant to be visible.
3. `evaluate {expression:"'ok'"}` - page-context JS, bypassing the content-script CSP.
4. `screenshot` - a PNG in your capture directory *and* returned inline.
5. `record {action:"start"}` … `record {action:"stop", path:"clip.webm"}` - a `.webm`
of the tab. No toolbar click and no user gesture needed; the toolbar icon is inert
by design and starts nothing.
6. `detach` - clears the banner.
To see the difference the trusted path makes, install a listener and compare:
```js
// via evaluate
window.__e = []; document.querySelector("button")
.addEventListener("click", e => window.__e.push(e.isTrusted));
```
`click` reports `false`; `realClick` reports `true`. That contrast is the whole point
of the project, and the browser test lane asserts on it directly.
---
## Running the tests
The suite has **two lanes**, and the split is the point.
### Offline lane - no browser, runs in CI
```sh
npm test # node test/run.mjs --lane=offline
```
Completes in well under a second and needs nothing but Node. It runs a **real MCP
handshake in-process** against `src/server.js` (via the SDK's in-memory transport), so
it asserts on the surface the server actually publishes:
- every published tool has an extension handler, **and vice versa** - the failure the
mirrored architecture invites
- no tool name is claimed by two handler groups (they merge by spread, so a duplicate
would silently lose)
- every tool carries a real description and the universal `tabId`/`frameId`/`expectUrl`
scope
- **every tool is exercised by at least one test** - add a tool without a test and CI
fails, no browser required
- the capture path allowlist really refuses `..`, deep `..`, absolute paths, and
**symlinked** escapes, tested against the real resolver
- the ban list is checked by *behaviour* - it blocks what it claims to and doesn't
over-block ordinary sites
- the hub binds loopback only, the tokens match on both sides, the manifest requests no
over-broad permissions, the toolbar icon is inert, and no `*.pem` is committed
### Browser lane - drives real Chrome
```sh
# 1. Disconnect the MCP client (close Claude Code, or disable this server for the run)
# 2. Free port 9876 - the hub is long-lived and outlives the session that spawned it
pkill -f src/hub.js
# 3. Start the suite; it binds 9876 itself and waits for the extension
npm run test:browser
# 4. Reload the extension in chrome://extensions so it connects to the suite
```
> ⚠️ **Step 1 is not optional.** A *connected* MCP client respawns the hub every ~1.2s
> whenever it finds the socket gone, so it takes port 9876 straight back and the suite
> dies with `EADDRINUSE`. Killing the hub while a client is still attached does not
> help - the client just starts another one.
The suite stands up a fixture server and a bridge speaking the **same wire protocol as
the real hub**, so a passing run exercises the actual message contract. Each suite
mirrors a tool group, and every test starts from a reset fixture page - no test
inherits another's mutations.
At the end it prints **tool coverage** and fails if any of the 45 tools went
unexercised.
### Options
| Command | Effect |
| --- | --- |
| `npm test` | offline lane only - the CI gate |
| `npm run test:browser` | browser lane only |
| `npm run test:all` | both |
| `npm run test:list` | list every suite and test without running |
| `node test/run.mjs --grep=fill` | only tests whose suite/name matches |
### Test layout
```
test/
├── run.mjs # CLI: lanes, filtering, coverage, reporting
├── lib/
│ ├── runner.js # suite registry, isolation, timeouts
│ ├── assert.js # assertions with diagnostic messages
│ ├── wait.js # eventually() - polling, not fixed sleeps
│ ├── mcp-probe.js # real in-process MCP handshake
│ ├── bridge.js # stands in for the hub; tracks tool coverage
│ ├── fixture-server.js # serves the fixture pages
│ └── page.js # the browser session + per-test reset
├── fixtures/
│ ├── index.html # the fixture page (a real file, with __reset())
│ └── frame.html # child frame, for frameId targeting
└── suites/
├── 01-contract.suite.js # offline
├── 02-security.suite.js # offline
├── 10-navigation.suite.js
├── 20-tabs.suite.js
├── 30-perception.suite.js
├── 40-interaction.suite.js
├── 50-trusted-input.suite.js
├── 60-observability.suite.js
└── 70-capture.suite.js
```
---
## Security notes
This extension can drive your logged-in browser. Read this section.
- **Loopback only.** The hub binds `127.0.0.1`, so it is **not reachable from the
LAN** - only from processes on this machine.
- **Token handshake.** A peer must present `AUTH_TOKEN` on connect or the hub drops
it. **Change it** from the shipped default (identical constant in `src/config.js`
and `extension/src/config.js`) - it is what stops another local process from
driving your browser.
- **File writes are confined** to the capture directory.
`src/capture/capture-path.js` resolves every requested path and refuses anything
outside it, including via `..` traversal *and* via symlinked subdirectories. This
matters more than it looks: arbitrary-path writes are effectively code execution.
- **Host ban list.** `BANLIST` in `extension/src/config.js` blocks navigation and
scripting on sensitive domains (banking, PayPal, Gmail). Adjust it to your needs.
**Note:** screenshots and recording capture rendered pixels and are *not* filtered
by the ban list.
- **The debugger banner is a feature.** CDP tools attach `chrome.debugger`, showing a
persistent yellow *"being debugged"* bar. That is your visible signal that something
is driving the tab. `detach` removes it.
- **`evaluate` runs arbitrary JS** in the page's real context.
- **Internal pages are off limits** - the extension cannot script `chrome://` or
`chrome-extension://` URLs.
- **The signing key is not in this repo.** The extension ID is pinned by the *public*
`key` in `extension/manifest.json`; the matching **private** key must stay outside
version control (`.gitignore` blocks `*.pem`). It is only needed to re-pack a
`.crx` under the same ID - loading unpacked does not use it.
---
## Architecture
The server and the extension are **mirrored**. Every tool group in `src/tools/` has a
handler file of the same name in `extension/src/handlers/`. Adding a tool means
touching exactly that pair - its schema and docs on one side, its implementation on
the other.
| Group | Server (schema + docs) | Extension (implementation) |
| --- | --- | --- |
| navigation | `src/tools/navigation.js` | `extension/src/handlers/navigation.js` |
| tabs | `src/tools/tabs.js` | `extension/src/handlers/tabs.js` |
| perception | `src/tools/perception.js` | `extension/src/handlers/perception.js` |
| interaction | `src/tools/interaction.js` | `extension/src/handlers/interaction.js` |
| trusted input | `src/tools/trusted-input.js` | `extension/src/handlers/trusted-input.js` |
| observability | `src/tools/observability.js` | `extension/src/handlers/observability.js` |
| capture | `src/tools/capture.js` | `extension/src/handlers/capture.js` |
Everything else is supporting infrastructure:
- **`bin/custom-chrome-dev-mcp.js`** - the executable you register with your MCP
client. It does nothing but start the server.
- **`src/config.js`** / **`extension/src/config.js`** - every tunable, one file per
side. `AUTH_TOKEN` and the port must match across the two.
- **`src/relay/hub-client.js`** - connects to the hub as `role:"mcp"`, spawns it when
absent, and turns each tool call into a request/response over the socket.
- **`src/hub.js`** - the long-lived relay owning `ws://127.0.0.1:9876`. Holds the one
extension socket plus every session's client and multiplexes between them. Re-tags
ids on the wire (they can collide across sessions) and self-exits if a hub already
owns the port.
- **`src/capture/capture-path.js`** - the write allowlist. Every capture path goes
through it.
- **`extension/src/connection.js`** - the hub socket plus the heartbeat. An MV3
service worker is torn down after ~30s idle, which silently drops the socket; a
sub-30s heartbeat keeps both alive, and an alarm revives the worker after a hard
kill.
- **`extension/src/tabs.js`** - which tab a call acts on (explicit `tabId` > pinned
tab > active tab), the `expectUrl` guard, and the ban list check.
- **`extension/src/walker-bridge.js`** + **`extension/src/page/walker.js`** - the
injected ISOLATED-world script with the stable element-ref system, and the only
module that knows how to reach it.
- **`extension/src/cdp/`** - `session.js` (attach/detach, `cdp()`, element centres),
`keyboard.js` (key names → CDP key events), `dialogs.js` (native dialog policy),
`buffers.js` (console + network ring buffers, capped at 500/tab).
- **`extension/src/recording/`** - `chrome.tabCapture` needs a user gesture an MCP
call never has, so recording uses CDP screencast instead: JPEG frames relayed to an
offscreen `MediaRecorder` (the service worker has no DOM).
- **`test/`** - two-lane suite: an offline CI gate that needs no browser, and a
browser lane that drives real Chrome. See [Running the tests](#running-the-tests).
---
## Project layout
```
.
├── bin/
│ └── custom-chrome-dev-mcp.js # executable entry - register THIS with your client
├── src/
│ ├── server.js # composes config + relay + tool registry
│ ├── config.js # port, token, capture dir, timeouts
│ ├── hub.js # long-lived relay owning :9876
│ ├── relay/
│ │ └── hub-client.js # session -> hub socket; call()
│ ├── capture/
│ │ └── capture-path.js # write allowlist for screenshots/recordings
│ └── tools/ # ONE FILE PER TOOL GROUP - the public surface
│ ├── index.js # the registry
│ ├── schemas.js # shared arg shapes + passthrough helper
│ ├── navigation.js
│ ├── tabs.js
│ ├── perception.js
│ ├── interaction.js
│ ├── trusted-input.js
│ ├── observability.js
│ └── capture.js
├── extension/ # Chrome MV3 extension
│ ├── manifest.json
│ └── src/
│ ├── background.js # service worker entry - wiring only
│ ├── config.js # token, banlist, buffer caps, asset paths
│ ├── connection.js # hub socket + MV3 keepalive heartbeat
│ ├── tabs.js # tab resolution, pinning, ban check
│ ├── walker-bridge.js # channel to the injected page script
│ ├── cdp/
│ │ ├── session.js # attach/detach, cdp(), element centres
│ │ ├── keyboard.js # key names -> CDP key events
│ │ ├── dialogs.js # native alert/confirm/prompt policy
│ │ └── buffers.js # console + network ring buffers
│ ├── recording/
│ │ ├── recorder.js # CDP screencast -> offscreen encoder
│ │ ├── offscreen.html
│ │ └── offscreen.js # MediaRecorder host
│ ├── page/
│ │ └── walker.js # injected DOM driver (ISOLATED world)
│ └── handlers/ # MIRRORS src/tools/ - one file per group
│ ├── index.js # the handler table + dispatch
│ ├── navigation.js
│ ├── tabs.js
│ ├── perception.js
│ ├── interaction.js
│ ├── trusted-input.js
│ ├── observability.js
│ └── capture.js
└── test/ # two lanes: offline (CI) + browser
├── run.mjs # CLI entry
├── lib/ # runner, assertions, bridge, fixtures, session
├── fixtures/ # the fixture pages, as real files
└── suites/ # one suite per tool group
```
---
## Credits
Inspired by [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp)
from the Chrome DevTools team. Independent reimplementation, not affiliated with,
endorsed by, or supported by Google.
---
## License
[MIT](LICENSE). Copyright (c) 2026 Haba Andrei.
Use it, fork it, ship it. The only condition is that the copyright notice and the
permission notice travel with any substantial copy.
[](https://m8ven.ai/mcp/habaandrei-custom-chrome-dev-mcp-lcatl0)This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues