---
title: "djust-browser-mcp"
slug: djust-browser-mcp
section: guides
order: 35
level: intermediate
description: "Chrome extension + MCP server pair that gives AI agents end-to-end interaction with a running djust app — record/replay regression tests, multi-session simulation, performance benchmarking, WS injection, 53 tools, no Playwright."
---

# djust-browser-mcp

`djust-browser-mcp` is a sibling of djust's [in-app MCP server](/guides/mcp-server/).
The in-app MCP runs inside your Django process and lets agents introspect
*server-side* state — view assigns, handler timings, the last traceback.
`djust-browser-mcp` runs as a **Chrome extension paired with a local Node
MCP server** and lets agents interact with the *running browser* — clicking,
typing, recording, replaying, asserting, benchmarking, throttling
WebSockets, snapshotting the DOM. 53 tools, sub-100 ms interaction
latency, no Playwright in the loop.

Use it when you want an agent to:

- author a regression test by interacting once and saving the recording,
- verify a multi-user broadcast flow by spawning N tabs and watching
  them stay in sync,
- catch a perf regression by benchmarking an event before and after a
  PR,
- prove a click hit djust's Rust fast path,
- simulate flaky networks (latency + drop rate) without `tc qdisc`,
- scan accessibility violations as part of CI,
- find dead `dj-click="..."` template handlers whose Python methods got
  renamed.

## Why a separate MCP

| | In-app MCP | djust-browser-mcp |
|---|---|---|
| Runs in | Django process | Chrome extension + Node server |
| Sees | Server state, view assigns, handler timings, tracebacks, SQL queries | DOM, mutations, WebSocket frames, console, network, screenshots |
| Drives | Code generation, framework introspection, project structure queries | Real user interactions in a real browser |
| Install | `pip install 'mcp[cli]'`, `djust mcp install` | `npm install`, `npm run build`, load Chrome extension, `claude mcp add` |
| Pairs with | djust-browser-mcp | In-app MCP |

The two are complementary. In a typical AI-driven debugging session
both are connected: the agent uses the in-app MCP to read state and the
last traceback, and uses djust-browser-mcp to reproduce the user's
interaction in the browser.

## Why not Playwright

The README's speed table:

| Operation | Playwright | djust-browser-mcp |
|---|---|---|
| Click a button | ~1–3 s | ~50–100 ms |
| Read element state | ~1–2 s | ~20–50 ms |
| Fill a form field | ~1–2 s | ~30–50 ms |
| Check a WebSocket frame | n/a | ~10 ms |
| Wait for VDOM patch | ~5–15 s (poll) | ~100 ms (event-driven) |
| Screenshot | ~2–3 s | ~200 ms |

Three reasons it's faster:

1. **Smart patch waiting via VDOM version.** Playwright polls; djust
   knows when the next VDOM patch landed because the framework
   increments a version counter on each one. The MCP waits on that
   counter changing, not on a CSS-selector-becoming-visible timeout.
2. **Real WebSocket frame visibility.** Playwright cannot read the
   WebSocket frames djust uses for state sync. djust-browser-mcp's
   `page-world` injection script hooks `WebSocket.prototype` and
   captures every frame in a ring buffer.
3. **In-process Chrome.** No protocol round-trip to a remote browser,
   no driver layer. The extension talks to the page via direct
   content/page-world JS and to the MCP server via a single
   `ws://127.0.0.1:9377` connection.

The trade-off: Chrome only, dev-server only, no headless, no
cross-browser. See [Limitations](#limitations).

## Architecture

```text
Claude / Cursor / Windsurf
        │  stdio (MCP protocol)
        ▼
MCP server (Node.js, port 9377)         ←  src/index.js, registers 53 tools
        │  WebSocket
        ▼
Chrome extension
   ├── background.js           ←  message router, tab management,
   │                              chrome.tabs.captureVisibleTab
   ├── content.js (isolated)   ←  DOM access, clicks, MutationObserver
   └── injected.js (page world)←  hooks WebSocket constructor + fetch/XHR,
                                   reads window.djust state
        │
        ▼
Your djust app on http://localhost
        │
        ▼
djust observability endpoints (optional)
   /_djust/observability/handler_timings/
   /_djust/observability/last_traceback/
   /_djust/observability/sql_queries/
   /_djust/observability/log/
```

The two-channel design is deliberate: DOM and screenshots travel via
Chrome extension APIs (which need elevated permissions); WebSocket
frames and `window.djust` state travel via the page-world injection
(which doesn't, but can't `captureVisibleTab`). The MCP server
multiplexes both.

## Install

### Prerequisites

- Chrome (Manifest V3)
- Node.js 18+
- A djust project running on `http://localhost:<port>` with `DEBUG=True`
- Claude Code, Cursor, Windsurf, or any MCP-capable client

### One-time setup

```bash
# 1. Build the MCP server
cd ~/online_projects/ai/djust-browser-mcp
npm install
npm run build           # produces dist/index.js

# 2. Register the MCP server with Claude Code
claude mcp add djust-browser -- node ~/online_projects/ai/djust-browser-mcp/dist/index.js
```

Equivalent manual config in `~/.claude/settings.json`:

```json
{
  "mcpServers": {
    "djust-browser": {
      "command": "node",
      "args": ["/Users/YOU/online_projects/ai/djust-browser-mcp/dist/index.js"]
    }
  }
}
```

For Cursor: same JSON, dropped into Cursor's MCP settings.

### Load the Chrome extension

1. Open `chrome://extensions`
2. Toggle **Developer mode** (top right).
3. Click **Load unpacked** → pick `~/online_projects/ai/djust-browser-mcp/extension/`.

The extension's popup should now show **Connected** when your djust dev
server is running. The MCP server picks the first free port between
`9377` and `9386` (override with `DJUST_MCP_PORT=...`).

### Start using it

```bash
# Start your djust dev server (DEBUG=True)
make dev

# Open http://localhost:<port> in Chrome (tools default to localhost tab targeting)
# Open Claude Code in a connected project
# Ask: "use the djust-browser MCP to take a dom_snapshot of the page"
```

If the agent reports the tool isn't available, the connection is broken
— see [Troubleshooting](#troubleshooting).

## Tool catalog

53 tools, grouped by intent. Every tool returns JSON; agents compose
them freely.

### DOM inspection (6)

- `dom_snapshot` — Full structured tree with attributes, computed
  styles, bounding boxes, template-source mapping.
  Params: `selector?`, `include_styles?`, `max_depth?`.
- `inspect_element` — Deep single-element inspection: attrs, styles,
  bound events, visibility.
  Params: `selector`.
- `query_selector` — Find elements by CSS selector.
  Params: `selector`, `limit?`.
- `find_by_template` — Find elements rendered from a specific template
  file (uses `data-dj-src` markers, DEBUG-only).
  Params: `template`, `line?`.
- `dom_mutations` — Replay attribute and node changes captured by
  `MutationObserver`.
  Params: `since?`, `limit?`, `type?`, `enabled?`.
- `dom_changes` — VDOM patches reconstructed from observed WebSocket
  frames (the patches the *server* sent), useful when correlating
  visual changes with handler activity.
  Params: `since?`, `limit?`.

### Interaction (7)

- `click` — Click with smart VDOM-version waiting; doesn't return until
  the resulting patch lands.
  Params: `selector`, `wait_for_patch?`.
- `type_text` — Type into a field with proper `input` / `change`
  events.
  Params: `selector`, `text`, `clear_first?`, `trigger_events?`.
- `select_option` — Pick a `<select>` option.
  Params: `selector`, `value`.
- `submit_form` — Fill multiple fields and submit in one call.
  Params: `selector`, `fields?`, `submit?`.
- `navigate` — Drive the URL bar.
  Params: `url`.
- `scroll_to` — Scroll an element into view.
  Params: `selector?`, `position?`.
- `highlight_element` — Visual red-border overlay for confirming the
  agent picked the right element.
  Params: `selector`, `duration?`.

### WebSocket (4)

- `ws_frames` — Captured frames (in/out) with ring-buffer cap.
  Params: `limit?`, `direction?`, `since?`, `event_name?`.
- `ws_status` — `readyState`, frame counts, current VDOM version.
- `ws_diagnostics` — Auto-detect duplicate events, missing responses,
  large payloads, high event rates.
- `ws_inject` — Inject a synthetic frame as if the server sent it
  (useful for testing presence updates without a second user).
  Params: `data` (string | object).

### State (3)

- `djust_state` — Current loading-manager state, bound events, VDOM
  version, components.
  Params: `scope?` (`all` | `loading` | `events` | `vdom` | `components`).
- `djust_state_diff` — Snapshot before / after an action; report only
  what changed.
  Params: `trigger_selector`, `observe_selector?`, `include_keys?`, `timeout_ms?`.
- `djust_eval` — Run JS in page context, scoped to `window.djust`.
  Params: `expression`.

### Performance (3)

- `benchmark_event` — Measure click → DOM-settled latency, return min /
  p50 / avg / p90 / max.
  Params: `trigger_selector`, `observe_selector?`, `samples?`, `warmup?`,
  `gap_ms?`, `timeout_ms?`.
- `benchmark_compare` — Run two benchmarks back-to-back and report the
  ratio.
  Params: `a: {trigger_selector, observe_selector?}`, `b: {…}`, `samples?`.
- `verify_fast_path` — Assert a click hit djust's Rust fast path
  (`parse_ms` below threshold).
  Params: `trigger_selector`, `observe_selector?`, `expect?`,
  `fast_threshold_ms?`, `timeout_ms?`.

### Assertion (2)

- `assert_event_dispatched` — Click and verify the server received the
  expected event with expected params.
  Params: `trigger_selector`, `expect_event`, `expect_params?`,
  `timeout_ms?`.
- `assert_no_console_errors` — Scan the console buffer for error-level
  entries since a timestamp.
  Params: `since_ms?`, `patterns_to_ignore?`.

### Diagnostics (4)

- `trace_event` — Single-click forensic timeline: click → WS send →
  WS recv → DOM mutation → DOM settled.
  Params: `trigger_selector`, `observe_selector?`, `timeout_ms?`.
- `client_perf_trace` — `PerformanceObserver` integration: long tasks,
  layout shifts, custom marks.
  Params: `trigger_selector`, `observe_selector?`, `settle_ms?`,
  `timeout_ms?`, `mark_prefix?`.
- `stress_test` — Fire events at a fixed rate, match SENT vs RECEIVED
  frame counts.
  Params: `trigger_selector`, `rate_hz`, `duration_s`, `observe_selector?`,
  `settle_timeout_ms?`.
- `ws_throttle` — Add outgoing-frame latency / drop rate. Useful for
  reproducing flaky-network bugs without `tc qdisc` or a proxy.
  Params: `delay_ms?`, `drop_rate?`, `clear?`.

### Recording / replay / fixtures (8 tools)

- `record_start` — Begin recording user-driven interactions.
  Params: `include_events?`.
- `record_stop` — End recording; returns the action list.
- `replay_actions` — Replay an action list at original or accelerated
  speed.
  Params: `actions`, `speed_multiplier?`, `timeout_ms?`.
- `save_fixture` — Persist an action list to
  `~/.djust-browser-fixtures/<name>.json`.
  Params: `name`, `actions`, `url?`, `description?`, `overwrite?`.
- `load_fixture` — Read a saved fixture by name.
  Params: `name`.
- `list_fixtures` — Enumerate every saved fixture.
- `run_fixture_suite` — Replay a glob of fixtures with assertions
  between them.
  Params: `pattern?`, `assert_console_clean?`, `inter_fixture_delay_ms?`,
  `speed_multiplier?`, `stop_on_failure?`, `patterns_to_ignore?`.
- `regression_suite` — Compose checks ad-hoc (event + state + fast-path
  + console + a11y) without persisting them as fixtures first.
  Params: `checks`, `stop_on_failure?`, `a11y_impact_at_least?`.

### Accessibility / visual (2)

- `accessibility_scan` — Run `axe-core` against the page or a region.
  Params: `selector?`, `include_tags?`, `exclude_tags?`,
  `impact_at_least?`.
- `visual_diff` — Screenshot before / after an action, pixel-diff,
  return change ratio.
  Params: `trigger_selector`, `observe_selector?`, `settle_ms?`,
  `timeout_ms?`, `threshold?`.

### Multi-session (1)

- `multi_session_simulate` — Spawn N tabs (1–20), each clicking M
  times, collect aggregated stats.
  Params: `url`, `count`, `trigger_selector`, `clicks_per_tab?`,
  `per_click_delay_ms?`, `hold_ms?`.

### Introspection (4)

- `list_event_handlers` — Enumerate every `dj-click` /
  `dj-submit` / `dj-change` / `dj-input` / `dj-keydown` / `dj-keyup`
  in the current DOM.
  Params: `include_disabled?`.
- `find_dead_bindings` — Cross-reference DOM handlers against the
  view's declared handler list; flag handlers in the template that no
  longer exist on the view.
  Params: `view_handlers`, `framework_handlers?`.
- `inspect_source` — Match a DOM element back to the template file +
  line that rendered it (DEBUG-only; reads the template from disk).
  Params: `selector`, `template_file_path`, `context_lines?`.
- `check_dj_id_stability` — Verify `dj-id` mappings don't change
  unexpectedly across re-renders.
  Params: `trigger_selector?`, `observe_selector?`, `settle_ms?`.

### Utilities (9)

- `console_messages` — Browser console buffer (filter by level / regex).
- `network_requests` — Fetch + XHR ring buffer (filter by URL pattern /
  status).
- `page_info` — URL, title, djust-detected, dev-server-running.
- `wait_for` — Wait for a selector / text / WS event.
- `screenshot` — `chrome.tabs.captureVisibleTab` PNG (returned as
  base64).
- `list_tabs` — Enumerate localhost tabs Chrome can see.
- `set_target_tab` — Explicitly pick which tab the tools act on
  (matters when multiple djust apps are open).
- `memory_snapshot` — Heap stats + DOM node count.
- `summarize_session` — Token-efficient digest of the last N ms across
  WS frames + console + DOM mutations + (optional) djust observability
  endpoints.

Tally: 6 (DOM) + 7 (Interaction) + 4 (WebSocket) + 3 (State) + 3
(Performance) + 2 (Assertion) + 4 (Diagnostics) + 8 (Recording /
replay / fixtures) + 2 (A11y / visual) + 1 (Multi-session) + 4
(Introspection) + 9 (Utilities) = **53 tools across 14 modules**.

## Workflows

### A. AI-authored regression test (record → save → replay)

The pitch: stop writing test scaffolding. Show the agent the bug once,
have it record what you did, save it as a fixture, then replay-with-
asserts in CI.

```text
agent → record_start({include_events: true})
[user (or agent) clicks through the bug repro]
agent → record_stop()                       → returns {actions: [...]}
agent → save_fixture({
          name: "checkout-discount-bug",
          actions: <the recording>,
          url: "/checkout/",
          description: "Discount stacking regression #1234",
        })
[later, in CI:]
agent → run_fixture_suite({
          pattern: "checkout-*",
          assert_console_clean: true,
          stop_on_failure: true,
        })
```

Fixtures land at `~/.djust-browser-fixtures/<name>.json` and are
plain JSON — commit them, share them, replay them at any speed.

### B. Multi-user flow test

Verify presence avatars render correctly when 5 users join at once, or
that a `LISTEN/NOTIFY` broadcast hits every connected reader.

```text
agent → multi_session_simulate({
          url: "http://localhost:8000/dashboard/",
          count: 5,
          trigger_selector: "[dj-click=\"add_widget\"]",
          clicks_per_tab: 10,
          per_click_delay_ms: 200,
          hold_ms: 1500,
        })
```

Returns aggregated stats: per-tab click counts, drop rate, latency
distribution, any stuck loading indicators. Catches: race conditions
in handler logic, broadcast misses, rate-limiting bugs, session-isolation
leaks.

### C. Performance regression catch

Land a PR that touches a hot path? Benchmark before and after.

```text
agent → benchmark_event({
          trigger_selector: "[dj-click=\"place_order\"]",
          observe_selector: "#order-confirmation",
          samples: 50,
          warmup: 5,
        })   → {min_ms, p50_ms, avg_ms, p90_ms, max_ms}

[on the PR branch:]
agent → benchmark_compare({
          a: {label: "main", trigger_selector: "..."},
          b: {label: "PR-branch", trigger_selector: "..."},
          samples: 50,
        })   → {ratio: 1.34, regression: true}

# And to confirm the Rust fast path actually fired:
agent → verify_fast_path({
          trigger_selector: "[dj-click=\"place_order\"]",
          fast_threshold_ms: 2,
        })
```

### D. WebSocket resilience test

Simulate a flaky mobile connection without `tc qdisc` or a proxy.

```text
agent → ws_throttle({delay_ms: 500, drop_rate: 0.1})
agent → click({selector: "[dj-click=\"submit_form\"]"})
agent → ws_status()                        → readyState, frame counts, stuck loaders
agent → ws_inject({                        # synthetic server push
          data: {type: "presence", users: [...]}
        })
agent → trace_event({                      # single-click forensic timeline
          trigger_selector: "[dj-click=\"reload_dashboard\"]",
          timeout_ms: 8000,
        })
agent → ws_throttle({clear: true})         # reset
```

### E. Accessibility baseline

Run axe-core in CI and fail builds on regressions.

```text
agent → accessibility_scan({
          include_tags: ["wcag2aa"],
          impact_at_least: "serious",
        })   → {violations: [...]}
```

Or compose into a regression suite that asserts a11y *and* event
correctness in one pass:

```text
agent → regression_suite({
          checks: [
            {name: "open-dialog",
             trigger: "[dj-click=\"open_settings\"]",
             assert_event: "open_settings",
             assert_a11y_clean: true},
            {name: "save-form",
             trigger: "[dj-click=\"save\"]",
             assert_event: "save",
             assert_state_changed: true,
             assert_console_clean: true,
             assert_a11y_clean: true},
          ],
          a11y_impact_at_least: "serious",
          stop_on_failure: true,
        })
```

### F. Dead-binding sweep

Find `dj-click="foo"` template handlers whose Python methods got
renamed or deleted.

```text
# Get the canonical handler list from the in-app MCP:
agent → (in-app) get_view_schema({view_name: "DashboardView"})
        → {handlers: ["refresh", "filter_by", "export_csv", ...]}

agent → find_dead_bindings({
          view_handlers: <the canonical list>,
          framework_handlers: ["validate_field"],
        })
        → {
          dead_bindings: [
            {selector: "button[dj-click=\"refesh\"]", handler: "refesh"},
            ...
          ],
          unreachable_handlers: ["export_pdf"],
        }
```

The agent tells you exactly which template attributes need editing.
The two MCPs compose naturally — the in-app MCP knows the
*authoritative* handler list from Python introspection; djust-browser-mcp
knows what's actually wired in the rendered DOM.

## Pairing with the in-app MCP

Best results come from running both. Example: an exception fires while
a user is in a wizard.

1. **In-app MCP → `get_last_traceback()`** — see the server-side stack.
2. **In-app MCP → `get_handler_timings()`** — confirm which handler ran.
3. **djust-browser-mcp → `summarize_session({window_ms: 60000})`** — get
   the last minute of WS frames + console + mutations on the client.
4. **djust-browser-mcp → `record_start()`**, ask the user to redo the
   action, **`record_stop()` + `save_fixture()`** — now you have a
   regression repro.
5. **djust-browser-mcp → `replay_actions()`** with the wizard reset to
   step 1 — verify the fix.

`summarize_session({include_server: true})` automatically pulls from
the in-app observability endpoints if your djust app has them mounted
— one tool call to get both halves.

## Limitations

- **Chrome only.** Manifest V3 extension. No Firefox, no Safari, no
  Edge story shipping.
- **DEBUG-only template source mapping.** `find_by_template` and
  `inspect_source` rely on the `data-dj-src` markers djust emits when
  `settings.DEBUG = True`. They can't run against production builds.
- **Dev-server intent, broad permission grant.** The extension manifest
  declares `<all_urls>` host permissions because
  `chrome.tabs.captureVisibleTab` requires it. In practice the tools
  default to localhost tab targeting (`set_target_tab` defaults to
  the first localhost tab). Treat it as a dev tool — install it in a
  dev Chrome profile, not your everyday browser.
- **Not headless.** Screenshots use `chrome.tabs.captureVisibleTab`,
  which requires a visible window. CI usage means a Chrome session in
  a `Xvfb` or similar virtual display.
- **Single-session MCP server.** One Chrome extension talks to one MCP
  server process at a time (single WebSocket upgrade).
- **In-memory ring buffers.** WS frames, console messages, network
  requests, and DOM mutations are kept in last-N buffers (50–500
  entries depending on type). Page reload clears them.
- **Accessibility scans depend on CSP.** `accessibility_scan` loads
  axe-core from `cdnjs.cloudflare.com`. Pages with strict CSP that blocks the
  CDN will fail the scan; whitelist or load axe-core locally.
- **Templates must be filesystem-accessible.** `inspect_source` reads
  template files from disk — the MCP server needs read access.

These are deliberate trade-offs against Playwright. The 20–30× speedup
above buys them.

## Troubleshooting

### "Tool not available" in Claude

The MCP server didn't start, or the registration is wrong.

```bash
# Confirm the server runs standalone
node ~/online_projects/ai/djust-browser-mcp/dist/index.js
# Should log "djust-browser-mcp listening on ws://127.0.0.1:9377"

# Confirm the registration
claude mcp list      # should show "djust-browser"

# Restart Claude Code after registration changes
```

### Extension popup says "Disconnected"

The extension can't reach the MCP server.

- Check `~/.claude/settings.json` for the right path to `dist/index.js`.
- Check whether something else is holding port 9377:
  `lsof -i :9377`. Kill it or override with `DJUST_MCP_PORT=9378`.
- Confirm Chrome's extension page shows no errors (`chrome://extensions`,
  toggle Developer mode, look for red badges).

### "Page not detected as djust"

By default `set_target_tab` picks the first localhost tab. If you're
proxying via a hostname like `app.local`, pass that tab explicitly via
`set_target_tab({url_pattern: "app.local"})` or by `tab_id`.

### `find_by_template` and `inspect_source` return nothing

`data-dj-src` markers are DEBUG-only. Confirm `settings.DEBUG = True`
and that you're hitting the dev server (not a built static deploy).

### `accessibility_scan` fails with CSP error

axe-core loads from `https://cdnjs.cloudflare.com`. Add it to your CSP
`script-src` allowlist for the dev server, or vendor axe-core locally
(see the README's "Self-hosting axe-core" section).

### Recording captures nothing

`record_start` only captures real interactions, not synthetic ones from
other tools. To record an agent-driven flow, drive the interactions
through `click` / `type_text` / etc. between `record_start` and
`record_stop` — these *do* capture into the recording.

### CI fails with "no display"

`screenshot` and `visual_diff` need a visible Chrome window. Run under
`Xvfb`:

```bash
xvfb-run --server-args="-screen 0 1920x1080x24" \
  npm test
```

## See also

- **[MCP Server](/guides/mcp-server/)** — the in-app sibling MCP for
  server-side state, handler timings, and traceback access.
- **[Time-Travel Debugging](/guides/time-travel-debugging/)** — pairs
  with `record_start` / `replay_actions` for stateful repro.
- **[Hot View Replacement](/guides/hot-view-replacement/)** — keeps
  recorded fixtures viable across template edits.
- **Source**: [`djust-browser-mcp` on GitHub](https://github.com/djust-org/djust-browser-mcp)
  — README, SPEC, ROADMAP, the 14 tool modules.
