DEV Community

johann
johann

Posted on

How to Connect AI Agents to Web Apps with Durable Action Receipts & Clean DOM Inspection: An MCP Browser Bridge

How to Connect AI Agents to Web Apps with Durable Action Receipts & Clean DOM Inspection: An MCP Browser Bridge in Python

In 2026, autonomous AI agents (Claude Desktop, Cursor, OpenAI Codex, LangGraph) are routinely tasked with interacting with live web applications: filling checkout forms, retrieving invoices from SaaS portals, triggering CI/CD pipelines, and scraping protected dashboards.

However, developers building agentic workflows quickly run into three severe production bottlenecks:

  1. Context Window Exhaustion: Dumping raw HTML (often 1MB to 5MB with inline SVG, JSON hydration payloads, and telemetry scripts) exhausts the LLM's context window within 2 or 3 turns, driving token costs through the roof.
  2. Lack of Cryptographic Action Receipts: If an agent performs an irreversible transaction—like submitting a payment form or modifying permissions—there is no tamper-evident audit record proving what the DOM state was before and after the action.
  3. Zombie Memory Leaks: Unmanaged headless browser contexts stay resident in RAM when agents drop connections or fail unexpectedly, consuming gigabytes of host memory.

To solve this, we designed and built BrowserBridge: an enterprise Model Context Protocol (MCP) server in Python that transforms noisy web pages into compact, token-efficient Accessibility Object Models (AOM) (~95% token reduction), enforces strict session pooling with automated zombie-reaping, and seals every agent action with a cryptographic SHA-256 receipt.


🏗️ The System Architecture

Instead of having your LLM parse raw HTML or blindly dispatch click coordinates, BrowserBridge sits as an auditable MCP sidecar:

           +-----------------------------------------------+
           |       AI Agent / LLM (Claude, Cursor, Swarm)  |
           +-----------------------------------------------+
                                   |
                          (MCP JSON-RPC Tools)
                                   v
           +-----------------------------------------------+
           |          BrowserBridge Pro MCP Server         |
           +-----------------------------------------------+
              /                    |                    \
             v                     v                     v
   +-------------------+  +-------------------+  +-------------------+
   |  AOM DOM Filter   |  | Session Pool Mgr  |  |  Durable Receipts |
   |  (-95% token cost)|  | (Anti-Zombie TTL) |  |   (SQLite WAL)    |
   +-------------------+  +-------------------+  +-------------------+
             \                     |                     /
              +--------------------+--------------------+
                                   |
                                   v
                      +-------------------------+
                      |   Target Web App DOM    |
                      +-------------------------+
Enter fullscreen mode Exit fullscreen mode

1. Slashing Token Bloat: The Accessibility Object Model (AOM)

Raw HTML is packed with tags irrelevant to an AI agent: <style>, <script>, <svg>, tracking pixels, and deep nesting. What an agent actually needs to know is:

  • What buttons exist?
  • What links can be followed?
  • What text fields require input?

Here is the Python implementation that converts messy HTML into structured, interactive @node elements:

import re
from typing import Dict, Any, List

def extract_accessibility_tree(html_content: str, form_state: Dict[str, str]) -> List[Dict[str, Any]]:
    aom_nodes = []
    node_counter = 1

    # 1. Extract Buttons
    for m in re.finditer(r"<button([^>]*)>(.*?)</button>", html_content, re.IGNORECASE | re.DOTALL):
        attrs, content = m.groups()
        text = re.sub(r"<[^>]+>", "", content).strip() or "Unnamed Button"
        aom_nodes.append({
            "id": f"@node-{node_counter}",
            "role": "button",
            "name": text[:50],
            "selector": f"button:contains('{text[:20]}')"
        })
        node_counter += 1

    # 2. Extract Links
    for m in re.finditer(r"<a\s+([^>]*href=[\"']([^\"']*)[\"'][^>]*)>(.*?)</a>", html_content, re.IGNORECASE | re.DOTALL):
        _, href, content = m.groups()
        text = re.sub(r"<[^>]+>", "", content).strip()
        if text and not href.startswith("javascript:"):
            aom_nodes.append({
                "id": f"@node-{node_counter}",
                "role": "link",
                "name": text[:50],
                "target_url": href,
                "selector": f"a[href='{href}']"
            })
            node_counter += 1

    # 3. Extract Text Input Fields
    for m in re.finditer(r"<input\s+([^>]*)/?>", html_content, re.IGNORECASE):
        attrs = m.group(1)
        name_m = re.search(r"name=[\"']([^\"']*)[\"']", attrs, re.IGNORECASE)
        type_m = re.search(r"type=[\"']([^\"']*)[\"']", attrs, re.IGNORECASE)
        name_val = name_m.group(1) if name_m else f"input_{node_counter}"
        type_val = type_m.group(1) if type_m else "text"

        aom_nodes.append({
            "id": f"@node-{node_counter}",
            "role": "textbox" if type_val in ("text", "email", "password", "search") else type_val,
            "name": name_val,
            "selector": f"input[name='{name_val}']",
            "current_value": form_state.get(name_val, "")
        })
        node_counter += 1

    return aom_nodes[:50]
Enter fullscreen mode Exit fullscreen mode

By presenting the LLM with @node-1 (role: button, name: "Checkout") instead of 250 lines of tailwind divs, prompt tokens drop by ~95% while dramatically improving selector accuracy.


2. Cryptographic Durability: Action Receipts Engine

When an agent interacts with a web interface, proving what happened and when is critical for compliance and debugging.

BrowserBridge calculates a SHA-256 fingerprint of the DOM before the action (pre_state_hash) and immediately after execution (post_state_hash), persisting the transition to an atomic SQLite WAL ledger:

import uuid
import sqlite3
from datetime import datetime, timezone
from typing import Dict, Any

class DurableReceiptsEngine:
    def __init__(self, db_path: str = "receipts.db"):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path, isolation_level=None) as conn:
            conn.execute("PRAGMA journal_mode=WAL;")
            conn.execute("""
                CREATE TABLE IF NOT EXISTS action_receipts (
                    receipt_id TEXT PRIMARY KEY,
                    session_id TEXT NOT NULL,
                    action_type TEXT NOT NULL,
                    target TEXT NOT NULL,
                    payload TEXT,
                    pre_state_hash TEXT NOT NULL,
                    post_state_hash TEXT NOT NULL,
                    status TEXT DEFAULT 'success',
                    created_at TEXT NOT NULL
                );
            """)

    def issue_receipt(self, session_id: str, action_type: str, target: str,
                      pre_state_hash: str, post_state_hash: str,
                      payload: str = "") -> Dict[str, Any]:
        receipt_id = f"rcpt_{uuid.uuid4().hex[:12]}"
        now = datetime.now(timezone.utc).isoformat()

        with sqlite3.connect(self.db_path, isolation_level=None) as conn:
            conn.execute("""
                INSERT INTO action_receipts (
                    receipt_id, session_id, action_type, target, payload,
                    pre_state_hash, post_state_hash, status, created_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, 'success', ?);
            """, (receipt_id, session_id, action_type, target, payload, pre_state_hash, post_state_hash, now))

        return {
            "receipt_id": receipt_id,
            "session_id": session_id,
            "action_type": action_type,
            "target": target,
            "pre_state_hash": pre_state_hash[:16],
            "post_state_hash": post_state_hash[:16],
            "timestamp": now
        }
Enter fullscreen mode Exit fullscreen mode

Auditing State Continuity

Using verify_action_trail(session_id), you can verify that the sequence of actions has not been tampered with or skipped:

def verify_action_trail(self, session_id: str) -> bool:
    receipts = self.list_receipts(session_id)
    for i in range(len(receipts) - 1):
        if receipts[i]["post_state_hash"] != receipts[i + 1]["pre_state_hash"]:
            return False # Chain discontinuity detected!
    return True
Enter fullscreen mode Exit fullscreen mode

3. Preventing Memory Leaks: The Anti-Zombie Session Pool

Headless browser automation easily leaks memory when agents abandon sessions mid-flight. BrowserBridge solves this with an automated session reaper:

from datetime import datetime, timezone, timedelta
from typing import Dict, List

class SessionPoolManager:
    def __init__(self, idle_ttl_seconds: int = 180):
        self.idle_ttl_seconds = idle_ttl_seconds
        self.sessions: Dict[str, BrowserSession] = {}

    def reap_zombies(self) -> List[str]:
        now = datetime.now(timezone.utc)
        reaped = []
        for sid, sess in list(self.sessions.items()):
            last_active = datetime.fromisoformat(sess.last_active)
            if now - last_active > timedelta(seconds=self.idle_ttl_seconds):
                del self.sessions[sid]
                reaped.append(sid)
        return reaped
Enter fullscreen mode Exit fullscreen mode

Whenever any session is requested, or on a periodic tick, sessions exceeding the inactivity window are safely terminated and removed from host memory.


4. Configuring with Claude Desktop or Cursor

BrowserBridge adheres to the standard Model Context Protocol (MCP) JSON-RPC specification. Adding it to Claude Desktop or Cursor is as simple as adding a snippet to claude_desktop_config.json:

{
  "mcpServers": {
    "browserbridge": {
      "command": "python",
      "args": ["-m", "browserbridge.server"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, your AI agent has direct access to:

  • navigate_page(url)
  • extract_clean_text()
  • extract_accessibility_tree()
  • click_element(selector)
  • fill_form_field(selector, value)
  • verify_action_trail(session_id)

🚀 Get Started: Open-Source & Enterprise

The full open-source BrowserBridge Lite is available immediately on GitHub with an MIT license:

👉 GitHub Repository: https://github.com/johannschiffer/browserbridge-mcp

If your team requires the Enterprise Pro Edition—including the persistent SQLite WAL receipts engine, multi-tenant session pool reaper, resilient selector resolver for dynamic SPAs, containerized Docker Compose manifests, and 100% passing Pytest suite:

👉 Get BrowserBridge Pro on Gumroad ($14.99 USD)


How do you handle auditability and token bloat when connecting AI agents to web apps? Let's discuss in the comments below!

Top comments (0)