Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

12 min read

djust-browser-mcp

djust-browser-mcp is a sibling of djust's in-app 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 MCPdjust-browser-mcp
Runs inDjango processChrome extension + Node server
SeesServer state, view assigns, handler timings, tracebacks, SQL queriesDOM, mutations, WebSocket frames, console, network, screenshots
DrivesCode generation, framework introspection, project structure queriesReal user interactions in a real browser
Installpip install 'mcp[cli]', djust mcp installnpm install, npm run build, load Chrome extension, claude mcp add
Pairs withdjust-browser-mcpIn-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:

OperationPlaywrightdjust-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 framen/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.

Architecture

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

# 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:

{
  "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

# 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.

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_statusreadyState, 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_tracePerformanceObserver 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.
  • screenshotchrome.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.

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.

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.

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.

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.

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:

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.

# 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.

# 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:

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

See also

Spotted a typo or want to improve this page? Edit on GitHub →