hanzo-tools-browser 0.5.6: three orthogonal tools (browser / cdp / playwright)

Decomplects the browser surface. Previously: BrowserTool with three
braided transports (ZAP, CDP bridge, Playwright) plus a CdpTool alias
that was just a name change. Now: three peer tools, one transport
strategy each, env-gated registration.

Tools (each default-on, each disable-able):
- `browser`    — high-level action surface, auto-routes transport.
                 Disable: HANZO_BROWSER_TOOL_DISABLED=1
- `cdp`        — raw Chrome DevTools Protocol method dispatch
                 (`method=`, `params=`). No Playwright fallback.
                 Disable: HANZO_CDP_TOOL_DISABLED=1
- `playwright` — same action surface as `browser` but pinned to
                 backend=playwright (no extension / CDP-bridge).
                 Disable: HANZO_PLAYWRIGHT_TOOL_DISABLED=1

Transport knobs stay orthogonal to tool selection:
- HANZO_ZAP_DISABLED=1        — don't auto-start the in-process ZAP server
- HANZO_CDP_BRIDGE_ENABLED=1  — opt back into the legacy HTTP bridge
- BROWSER_TRANSPORT=zap|http|auto
- BROWSER_BACKEND=firefox|chrome|extension|playwright|auto

Lifecycle (ZAP server + CDP bridge background threads) moved out of
__init__.py into lifecycle.py. __init__.py is now a pure re-export
surface that resolves TOOLS at import time based on env flags.

CdpTool is now a genuine peer of BrowserTool — method-oriented, not
action-oriented — replacing the alias-subclass from 0.5.5.
This commit is contained in:
2026-06-10 08:41:13 -07:00
parent 78d0446ad2
commit f17ed7fb1e
6 changed files with 684 additions and 286 deletions
@@ -1,24 +1,36 @@
"""Browser automation tools for Hanzo AI.
"""Browser automation tools — decomplected surface.
Two-process architecture (since 0.5.0):
Three orthogonal MCP tools, all default-enabled, all disable-able by env:
[Browser ext] --ZAP/WS--> [hanzo-mcp (this Python pkg)] --stdio--> [Agent]
* ``browser`` — high-level action surface. Auto-routes through the
in-process ZAP server (browser extension), the legacy
CDP HTTP bridge, or Playwright. Disable with
``HANZO_BROWSER_TOOL_DISABLED=1``.
The hanzo-mcp process hosts a ZAP server on an OS-assigned ephemeral port
and advertises it via mDNS under ``_hanzo._tcp.local.`` (HIP-0069).
Browser extensions browse mDNS and connect directly. No node bridge is
in the critical path, no well-known port pool, no lockfile arbitration.
* ``cdp`` — raw Chrome DevTools Protocol method dispatch. Same
transports as ``browser`` minus the Playwright fallback.
Disable with ``HANZO_CDP_TOOL_DISABLED=1``.
Legacy HTTP bridge (cdp_bridge_server.py on :9223/9224) is retained as an
opt-in fallback for non-ZAP MCP clients. Enable with
``HANZO_CDP_BRIDGE_ENABLED=1``.
* ``playwright`` — Playwright-pinned action surface. Same actions as
``browser`` but never touches the extension or CDP
bridge. Disable with ``HANZO_PLAYWRIGHT_TOOL_DISABLED=1``.
Independent transport knobs (orthogonal to which tools are surfaced):
* ``HANZO_ZAP_DISABLED=1`` — don't auto-start the ZAP server.
* ``HANZO_CDP_BRIDGE_ENABLED=1`` — opt back into the legacy HTTP bridge.
* ``BROWSER_TRANSPORT=zap|http|auto`` — pin transport (default ``auto``).
* ``BROWSER_BACKEND=firefox|chrome|extension|playwright|auto`` — backend
preference for ``browser``.
Lifecycle (ZAP server, CDP bridge threads) lives in ``lifecycle.py``.
"""
import os
import asyncio
from __future__ import annotations
import logging
import threading
from typing import Optional
import os
from typing import TYPE_CHECKING
from mcp.server import FastMCP
@@ -34,6 +46,15 @@ from hanzo_tools.browser.browser_tool import (
create_browser_tool,
launch_browser_server,
)
from hanzo_tools.browser.cdp_tool import CdpTool
from hanzo_tools.browser.playwright_tool import PlaywrightTool
from hanzo_tools.browser.lifecycle import (
CDP_BRIDGE_AVAILABLE,
ensure_zap_server,
start_cdp_bridge,
stop_cdp_bridge,
stop_zap_server,
)
from hanzo_tools.browser.zap_server import (
ZapClient,
ZapServer,
@@ -42,57 +63,115 @@ from hanzo_tools.browser.zap_server import (
shutdown_server,
)
logger = logging.getLogger(__name__)
# CDP Bridge for browser extension integration (legacy, opt-in)
try:
if TYPE_CHECKING:
from hanzo_tools.browser.cdp_bridge_server import (
WEBSOCKETS_AVAILABLE as CDP_BRIDGE_AVAILABLE,
CDPBridgeClient,
CDPBridgeServer,
)
except ImportError:
CDP_BRIDGE_AVAILABLE = False
CDPBridgeServer = None
CDPBridgeClient = None
# Global CDP bridge server instance (singleton, legacy path)
_cdp_bridge_server: Optional["CDPBridgeServer"] = None
_cdp_bridge_thread: Optional[threading.Thread] = None
_cdp_bridge_loop: Optional[asyncio.AbstractEventLoop] = None
# Re-export CDP-bridge classes when available (legacy callers).
try:
from hanzo_tools.browser.cdp_bridge_server import (
CDPBridgeClient,
CDPBridgeServer,
)
except ImportError: # pragma: no cover
CDPBridgeClient = None # type: ignore[assignment]
CDPBridgeServer = None # type: ignore[assignment]
# Global ZAP server lifecycle (canonical path)
_zap_thread: Optional[threading.Thread] = None
_zap_loop: Optional[asyncio.AbstractEventLoop] = None
_zap_started_event: Optional[threading.Event] = None
logger = logging.getLogger(__name__)
class CdpTool(BrowserTool):
"""Alias of BrowserTool surfaced under the ``cdp`` name.
Same action surface and transport stack as ``browser`` — both call into
the in-process ZAP server (canonical) and fall back to the legacy CDP
HTTP bridge or Playwright. Exposed as a separate tool so callers that
think in terms of *Chrome DevTools Protocol* can reach the same control
surface under a recognizable name. There is one implementation; this
class only changes ``name``.
# === Tools registry — gated by env ====================================
def _env_disabled(*names: str) -> bool:
return any(os.environ.get(n, "").lower() in ("1", "true", "yes") for n in names)
def _resolve_tools() -> list[type[BaseTool]]:
"""Build TOOLS list at import time based on env flags.
Each tool is independently disable-able. Default: all three on.
"""
tools: list[type[BaseTool]] = []
name = "cdp"
if not _env_disabled("HANZO_BROWSER_TOOL_DISABLED"):
tools.append(BrowserTool)
if not _env_disabled("HANZO_CDP_TOOL_DISABLED"):
tools.append(CdpTool)
if not _env_disabled("HANZO_PLAYWRIGHT_TOOL_DISABLED"):
tools.append(PlaywrightTool)
return tools
# Tools list for entry point discovery
TOOLS = [BrowserTool, CdpTool]
TOOLS: list[type[BaseTool]] = _resolve_tools()
# === Registration entry point ==========================================
def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""Register browser tools with the MCP server.
Starts the in-process ZAP server (unless ``HANZO_ZAP_DISABLED=1``) so
the browser extension can discover this MCP via mDNS. The legacy CDP
HTTP bridge (port 9223/9224) stays off by default — opt in with
``HANZO_CDP_BRIDGE_ENABLED=1`` or ``cdp_bridge=True`` kwarg.
Which tools get registered is controlled by env flags:
* HANZO_BROWSER_TOOL_DISABLED
* HANZO_CDP_TOOL_DISABLED
* HANZO_PLAYWRIGHT_TOOL_DISABLED
"""
headless = kwargs.get("headless", True)
cdp_endpoint = kwargs.get("cdp_endpoint")
backend = kwargs.get("backend")
# Canonical lifecycle: in-process ZAP server.
if backend != "playwright":
ensure_zap_server()
# Optional legacy lifecycle: CDP HTTP bridge.
if kwargs.get(
"cdp_bridge",
os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower() in ("1", "true", "yes"),
) and CDP_BRIDGE_AVAILABLE and backend != "playwright":
start_cdp_bridge()
registered: list[BaseTool] = []
for tool_class in TOOLS:
if tool_class is BrowserTool:
tool = create_browser_tool(
headless=headless, cdp_endpoint=cdp_endpoint, backend=backend
)
elif tool_class is PlaywrightTool:
# PlaywrightTool forces backend internally; respect headless+endpoint.
tool = PlaywrightTool(headless=headless, cdp_endpoint=cdp_endpoint)
else:
# CdpTool, future peers — no-arg constructor.
tool = tool_class()
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
return registered
def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""Standard entry point called by tool-discovery hosts."""
return register_browser_tools(mcp_server, **kwargs)
__all__ = [
# Main tool
# Tools (the three peers)
"BrowserTool",
"CdpTool",
"PlaywrightTool",
# Factory + module-level instance (existing public API)
"browser_tool",
"create_browser_tool",
# Browser pool
"BrowserPool",
"launch_browser_server",
# ZAP (canonical, since 0.5.0)
# ZAP (canonical)
"ZapServer",
"ZapClient",
"get_or_start_server",
@@ -104,223 +183,15 @@ __all__ = [
"CDP_BRIDGE_AVAILABLE",
"start_cdp_bridge",
"stop_cdp_bridge",
# Lifecycle (now in lifecycle.py)
"ensure_zap_server",
"stop_zap_server",
# Availability check
"PLAYWRIGHT_AVAILABLE",
# Backend helper
"get_backend",
# Registration
"TOOLS",
"register_browser_tools",
"register_tools",
]
def _run_zap_server(host: str) -> None:
"""Run the ZAP server in a dedicated background thread.
The MCP's main loop is owned by FastMCP/stdio; we keep ZAP isolated so
its websockets server has its own asyncio event loop and never fights
with stdio for cycles. The thread lives for the MCP's lifetime.
"""
global _zap_loop, _zap_started_event
_zap_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_zap_loop)
async def _bootstrap() -> None:
srv = await get_or_start_server(
host=host,
agent_label=os.environ.get("HANZO_AGENT_LABEL"),
)
if _zap_started_event is not None:
_zap_started_event.set()
if srv is None:
return
# Idle forever; the websockets server owns its own tasks.
while True:
await asyncio.sleep(3600)
try:
_zap_loop.run_until_complete(_bootstrap())
except Exception as e:
logger.error("ZAP server thread crashed: %s", e, exc_info=True)
def _ensure_zap_server() -> bool:
"""Start the in-process ZAP server if not already running.
Returns True if the server is alive after this call.
"""
global _zap_thread, _zap_started_event
if _zap_thread is not None and _zap_thread.is_alive():
return get_server() is not None
if os.environ.get("HANZO_ZAP_DISABLED", "").lower() in ("1", "true", "yes"):
return False
host = os.environ.get("HANZO_ZAP_HOST", "127.0.0.1")
_zap_started_event = threading.Event()
_zap_thread = threading.Thread(
target=_run_zap_server,
args=(host,),
daemon=True,
name="hanzo-zap-server",
)
_zap_thread.start()
# Wait for bind result so callers see a determinate state.
_zap_started_event.wait(timeout=2.0)
return get_server() is not None
def stop_zap_server() -> None:
"""Stop the in-process ZAP server (best-effort, non-blocking)."""
global _zap_thread, _zap_loop, _zap_started_event
if _zap_loop is not None:
try:
asyncio.run_coroutine_threadsafe(shutdown_server(), _zap_loop)
except Exception:
pass
_zap_loop = None
_zap_thread = None
_zap_started_event = None
def _run_cdp_bridge_server(host: str, port: int) -> None:
"""Run CDP bridge server in a background thread."""
global _cdp_bridge_server, _cdp_bridge_loop
_cdp_bridge_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_cdp_bridge_loop)
_cdp_bridge_server = CDPBridgeServer(host=host, port=port)
async def run():
await _cdp_bridge_server.start()
# Keep running until stopped
while True:
await asyncio.sleep(1)
try:
_cdp_bridge_loop.run_until_complete(run())
except Exception as e:
logger.error(f"CDP bridge server crashed: {e}", exc_info=True)
def start_cdp_bridge(
host: str = "localhost",
port: int = 9223,
auto_start: bool = True,
) -> bool:
"""Start the CDP bridge server for browser extension integration.
The CDP bridge enables communication between:
- hanzo-mcp's browser tool (via HTTP API on port 9224)
- Hanzo browser extension (via WebSocket on port 9223)
Args:
host: Host to bind to (default: localhost)
port: WebSocket port (default: 9223, HTTP API on 9224)
auto_start: Whether to auto-start (can be disabled via env var)
Returns:
True if bridge started, False otherwise
"""
global _cdp_bridge_thread
# Check if disabled via environment
if os.environ.get("HANZO_CDP_BRIDGE_DISABLED", "").lower() in ("1", "true", "yes"):
logger.debug("CDP bridge disabled via environment")
return False
if not CDP_BRIDGE_AVAILABLE:
logger.debug("CDP bridge not available (websockets not installed)")
return False
if _cdp_bridge_thread is not None and _cdp_bridge_thread.is_alive():
logger.debug("CDP bridge already running")
return True
# Override from environment
host = os.environ.get("HANZO_CDP_BRIDGE_HOST", host)
port = int(os.environ.get("HANZO_CDP_BRIDGE_PORT", str(port)))
try:
_cdp_bridge_thread = threading.Thread(
target=_run_cdp_bridge_server,
args=(host, port),
daemon=True,
name="cdp-bridge-server",
)
_cdp_bridge_thread.start()
logger.info(f"CDP bridge started on ws://{host}:{port}")
return True
except Exception as e:
logger.warning(f"Failed to start CDP bridge: {e}")
return False
def stop_cdp_bridge() -> None:
"""Stop the CDP bridge server."""
global _cdp_bridge_server, _cdp_bridge_thread, _cdp_bridge_loop
if _cdp_bridge_loop is not None:
try:
if _cdp_bridge_server is not None:
asyncio.run_coroutine_threadsafe(
_cdp_bridge_server.stop(), _cdp_bridge_loop
)
except Exception:
pass
_cdp_bridge_server = None
_cdp_bridge_thread = None
_cdp_bridge_loop = None
def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""Register browser tools with the MCP server.
Auto-starts the ZAP server (canonical path) so the Hanzo browser
extension can discover this MCP and dispatch actions to it directly.
The legacy CDP bridge (port 9223/9224) is opt-in via
``HANZO_CDP_BRIDGE_ENABLED=1`` or ``cdp_bridge=True`` kwarg.
Args:
mcp_server: The FastMCP server instance
**kwargs: Additional arguments (headless, cdp_endpoint, backend, cdp_bridge)
Returns:
List of registered tools
"""
headless = kwargs.get("headless", True)
cdp_endpoint = kwargs.get("cdp_endpoint")
backend = kwargs.get("backend")
# Canonical path: in-process ZAP server.
if backend != "playwright":
_ensure_zap_server()
# Legacy CDP bridge (opt-in only).
enable_cdp_bridge = kwargs.get(
"cdp_bridge",
os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower() in ("1", "true", "yes"),
)
if enable_cdp_bridge and CDP_BRIDGE_AVAILABLE and backend != "playwright":
start_cdp_bridge()
tool = create_browser_tool(
headless=headless, cdp_endpoint=cdp_endpoint, backend=backend
)
ToolRegistry.register_tool(mcp_server, tool)
return [tool]
def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""Register all browser tools with the MCP server.
This is the standard entry point called by the tool discovery system.
Auto-starts CDP bridge for browser extension integration.
"""
return register_browser_tools(mcp_server, **kwargs)
@@ -840,38 +840,27 @@ class BrowserTool(BaseTool):
self.backend = backend or get_backend()
self.timeout = 30000
# Auto-start the in-process ZAP server (canonical, since 0.5.0). One
# MCP = one ZAP server bound to the lowest free port from
# 9999..9995. The browser extension discovers it directly — no node
# bridge needed in the critical path.
if self.backend != "playwright" and os.environ.get("HANZO_ZAP_DISABLED", "").lower() not in ("1", "true", "yes"):
# Lifecycle (ZAP server, CDP bridge) lives in `lifecycle.py`.
# Import lazily to avoid a circular import on package load —
# __init__.py already pulls in this module before lifecycle.
if self.backend != "playwright":
try:
# Resolve lazily via importlib to avoid circular import on
# package load (hanzo_tools.browser imports BrowserTool at
# top level, but this constructor needs the bootstrap helper
# defined later in that same package).
import importlib
from hanzo_tools.browser.lifecycle import (
CDP_BRIDGE_AVAILABLE,
ensure_zap_server,
start_cdp_bridge,
)
pkg = importlib.import_module("hanzo_tools.browser")
ensure = getattr(pkg, "_ensure_zap_server", None)
if ensure is not None:
ensure()
except Exception as e:
logger.debug("zap server bootstrap failed: %s", e)
ensure_zap_server() # idempotent + respects HANZO_ZAP_DISABLED
# Legacy HTTP bridge (CDP bridge): kept as fallback transport for
# non-ZAP MCP clients. Not auto-started anymore — ZAP is canonical.
# Set HANZO_CDP_BRIDGE_ENABLED=1 to opt back in.
if (
self.backend != "playwright"
and os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower() in ("1", "true", "yes")
):
try:
from hanzo_tools.browser import CDP_BRIDGE_AVAILABLE, start_cdp_bridge
if CDP_BRIDGE_AVAILABLE:
if (
os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower()
in ("1", "true", "yes")
and CDP_BRIDGE_AVAILABLE
):
start_cdp_bridge()
except Exception:
pass
except Exception as e:
logger.debug("browser lifecycle bootstrap failed: %s", e)
@property
def description(self) -> str:
@@ -0,0 +1,267 @@
"""Raw Chrome DevTools Protocol method dispatch.
Decomplected from BrowserTool: `browser` is *action-oriented* (high-level
verbs like `navigate`, `click`); `cdp` is *method-oriented* (sends a CDP
method by name with raw params). Same backing transports (in-process ZAP
server → legacy HTTP bridge), no Playwright fallback — for that, use
`browser` or `playwright`.
Use this tool when you need a CDP method the high-level surface doesn't
expose, want to inspect raw protocol responses, or are wiring something
to the protocol directly.
Example::
cdp(action="send", method="Page.navigate", params={"url": "https://example.com"})
cdp(action="send", method="Runtime.evaluate", params={"expression": "1+1"})
cdp(action="tabs") # list connected tabs (Target.getTargets)
cdp(action="status") # connection status
cdp(action="list_browsers") # which providers (firefox/chrome/safari) are connected
"""
from __future__ import annotations
import logging
import os
from typing import Any, Optional, Union
from hanzo_tools.core import BaseTool
logger = logging.getLogger(__name__)
async def _dispatch_raw(
method: str,
params: Optional[dict] = None,
*,
browser: Optional[str] = None,
tab_id: Optional[Union[str, int]] = None,
client_id: Optional[str] = None,
timeout: float = 30.0,
) -> dict:
"""Dispatch a raw CDP method to the connected browser provider.
Path order — same as BrowserTool:
1. In-process ZAP server (microsecond round-trip; preferred).
2. Legacy HTTP bridge on :9224 (kept as fallback for non-ZAP clients).
Pin transport via ``BROWSER_TRANSPORT=zap|http|auto`` (default ``auto``).
"""
params = dict(params or {})
# Normalize tab id (accept "tab-123" or 123 or "123")
if tab_id is not None:
t = tab_id
if isinstance(t, str) and t.startswith("tab-"):
t = t[4:]
try:
t = int(t)
except (TypeError, ValueError):
pass
params.setdefault("tabId", t)
transport = os.environ.get("BROWSER_TRANSPORT", "auto").strip().lower()
if transport not in {"zap", "http", "auto"}:
transport = "auto"
# 1) ZAP path
if transport in {"zap", "auto"}:
try:
from hanzo_tools.browser.zap_server import get_server
srv = get_server()
if srv is not None and srv.has_client(browser=browser):
try:
raw = await srv.send(
method, params, browser=browser, client_id=client_id
)
return {"success": True, "transport": "zap", "method": method, "result": raw}
except Exception as e:
if transport == "zap":
return {"error": str(e), "transport": "zap", "method": method}
logger.debug("zap dispatch failed, falling back to http: %s", e)
except ImportError:
pass
if transport == "zap":
return {
"error": "ZAP transport selected but no extension client matched",
"transport": "zap",
"method": method,
}
# 2) HTTP fallback — legacy CDP bridge speaks raw CDP via a `cdp` action
try:
import aiohttp
payload: dict[str, Any] = {"action": "cdp", "method": method, "params": params}
if browser:
payload["browser"] = browser
if client_id:
payload["clientId"] = client_id
async with aiohttp.ClientSession() as session:
async with session.post(
"http://localhost:9224",
json=payload,
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
body = await resp.text()
try:
import json
parsed = json.loads(body)
except Exception:
parsed = {"raw": body}
parsed.setdefault("transport", "http")
parsed.setdefault("method", method)
if resp.status == 200:
parsed.setdefault("success", True)
else:
parsed.setdefault("status", resp.status)
parsed.setdefault("error", parsed.get("error") or body[:200])
return parsed
except Exception as e:
logger.debug("CDP dispatch HTTP fallback failed: %s", e)
return {"error": str(e), "transport": "http", "method": method}
class CdpTool(BaseTool):
"""Raw Chrome DevTools Protocol method dispatch.
Peer of ``browser`` (action-oriented) and ``playwright`` (Playwright API).
Sends any CDP method directly to a connected browser via the ZAP server
(extension) or legacy CDP HTTP bridge. Does NOT fall back to Playwright.
"""
name = "cdp"
@property
def description(self) -> str:
return """Raw Chrome DevTools Protocol dispatch — peer of `browser`.
ACTIONS:
- send : send a CDP method (method=, params=, tab_id=, target_browser=)
- tabs : Target.getTargets — list connected tabs
- status : Browser.getVersion — connection + version
- list_browsers : list extension providers (firefox/chrome/safari/edge) connected
- claim_browser / release_browser : exclusive-lease management
EXAMPLES:
- cdp(action="send", method="Page.navigate", params={"url": "https://example.com"})
- cdp(action="send", method="Runtime.evaluate", params={"expression": "document.title"})
- cdp(action="tabs")
- cdp(action="status")
Use `browser` for high-level verbs (navigate, click, screenshot).
Use `playwright` for headless Playwright automation.
"""
async def call(self, ctx, action: str = "send", **kwargs) -> dict[str, Any]:
return await self.execute(action=action, **kwargs)
async def execute(
self,
action: str = "send",
# Raw CDP
method: Optional[str] = None,
params: Optional[dict] = None,
# Routing
tab_id: Optional[Union[str, int]] = None,
target_browser: Optional[str] = None,
client_id: Optional[str] = None,
# Timeout
timeout: Optional[float] = None,
) -> dict[str, Any]:
t = float(timeout) if timeout else 30.0
# === Local actions (handled in-process) =====================
if action == "list_browsers":
try:
from hanzo_tools.browser.zap_server import get_server
srv = get_server()
if srv is None:
return {"error": "zap server not running"}
clients = []
for c in srv.clients:
clients.append(
{
"client_id": c.client_id,
"browser": getattr(c, "browser", None),
"label": getattr(c, "label", None),
}
)
return {"success": True, "browsers": clients, "count": len(clients)}
except Exception as e:
return {"error": str(e)}
if action == "claim_browser":
try:
from hanzo_tools.browser.zap_server import DEFAULT_LEASE_TTL, get_server
srv = get_server()
if srv is None:
return {"error": "zap server not running"}
client = srv.resolve_client(client_id=client_id, browser=target_browser)
if client is None:
return {"error": "no matching extension client"}
lease = srv.claim(client.client_id, ttl=t)
return {
"success": True,
"client_id": lease.client_id,
"holder": lease.holder,
"expires_at": lease.expires_at,
}
except Exception as e:
return {"error": str(e)}
if action == "release_browser":
try:
from hanzo_tools.browser.zap_server import get_server
srv = get_server()
if srv is None:
return {"error": "zap server not running"}
if client_id:
return {"success": srv.release(client_id), "client_id": client_id}
released = [c.client_id for c in list(srv.clients) if srv.release(c.client_id)]
return {"success": True, "released": released}
except Exception as e:
return {"error": str(e)}
# === Sugared CDP methods ==================================
sugared = {
"tabs": ("Target.getTargets", {}),
"status": ("Browser.getVersion", {}),
}
if action in sugared:
m, p = sugared[action]
return await _dispatch_raw(
m,
p,
browser=target_browser,
tab_id=tab_id,
client_id=client_id,
timeout=t,
)
# === Raw send ==============================================
if action == "send":
if not method:
return {
"error": "method required for action=send (e.g. 'Page.navigate')",
"action": "send",
}
return await _dispatch_raw(
method,
params,
browser=target_browser,
tab_id=tab_id,
client_id=client_id,
timeout=t,
)
return {
"error": f"unknown action '{action}'. Try: send, tabs, status, list_browsers, claim_browser, release_browser",
}
@@ -0,0 +1,210 @@
"""Lifecycle helpers for the browser package: ZAP server + legacy CDP bridge.
Decomplected out of ``__init__.py`` so the package's namespace is a pure
re-export surface. The MCP server (or any host) imports ``_ensure_zap_server``
and ``start_cdp_bridge`` from here when it wants the long-lived background
threads bound. Tools themselves never touch lifecycle directly.
Two background lifecycles, isolated:
* ZAP server — canonical. One MCP = one ZAP server bound to the lowest
free port from 9999..9995. Browser extension discovers
it via mDNS. Lifetime = MCP lifetime.
* CDP bridge — legacy HTTP fallback on :9223/:9224 for non-ZAP clients.
Opt-in: ``HANZO_CDP_BRIDGE_ENABLED=1``.
"""
from __future__ import annotations
import asyncio
import logging
import os
import threading
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from hanzo_tools.browser.cdp_bridge_server import CDPBridgeServer
logger = logging.getLogger(__name__)
# CDP bridge availability check
try:
from hanzo_tools.browser.cdp_bridge_server import (
WEBSOCKETS_AVAILABLE as CDP_BRIDGE_AVAILABLE,
CDPBridgeServer,
)
except ImportError: # pragma: no cover
CDP_BRIDGE_AVAILABLE = False
CDPBridgeServer = None # type: ignore[assignment]
# === Global state (one of each per process) ============================
_zap_thread: Optional[threading.Thread] = None
_zap_loop: Optional[asyncio.AbstractEventLoop] = None
_zap_started_event: Optional[threading.Event] = None
_cdp_bridge_server: Optional["CDPBridgeServer"] = None
_cdp_bridge_thread: Optional[threading.Thread] = None
_cdp_bridge_loop: Optional[asyncio.AbstractEventLoop] = None
# === ZAP (canonical) ====================================================
def _run_zap_server(host: str) -> None:
"""Run the ZAP server in a dedicated background thread."""
global _zap_loop, _zap_started_event
from hanzo_tools.browser.zap_server import get_or_start_server
_zap_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_zap_loop)
async def _bootstrap() -> None:
srv = await get_or_start_server(
host=host,
agent_label=os.environ.get("HANZO_AGENT_LABEL"),
)
if _zap_started_event is not None:
_zap_started_event.set()
if srv is None:
return
while True:
await asyncio.sleep(3600)
try:
_zap_loop.run_until_complete(_bootstrap())
except Exception as e:
logger.error("ZAP server thread crashed: %s", e, exc_info=True)
def ensure_zap_server() -> bool:
"""Start the in-process ZAP server if not already running.
Returns True if the server is alive after this call.
Idempotent — safe to call multiple times.
"""
global _zap_thread, _zap_started_event
if _zap_thread is not None and _zap_thread.is_alive():
from hanzo_tools.browser.zap_server import get_server
return get_server() is not None
if os.environ.get("HANZO_ZAP_DISABLED", "").lower() in ("1", "true", "yes"):
return False
host = os.environ.get("HANZO_ZAP_HOST", "127.0.0.1")
_zap_started_event = threading.Event()
_zap_thread = threading.Thread(
target=_run_zap_server,
args=(host,),
daemon=True,
name="hanzo-zap-server",
)
_zap_thread.start()
_zap_started_event.wait(timeout=2.0)
from hanzo_tools.browser.zap_server import get_server
return get_server() is not None
def stop_zap_server() -> None:
"""Stop the in-process ZAP server (best-effort, non-blocking)."""
global _zap_thread, _zap_loop, _zap_started_event
if _zap_loop is not None:
try:
from hanzo_tools.browser.zap_server import shutdown_server
asyncio.run_coroutine_threadsafe(shutdown_server(), _zap_loop)
except Exception:
pass
_zap_loop = None
_zap_thread = None
_zap_started_event = None
# === CDP bridge (legacy) ===============================================
def _run_cdp_bridge_server(host: str, port: int) -> None:
"""Run CDP bridge server in a background thread."""
global _cdp_bridge_server, _cdp_bridge_loop
_cdp_bridge_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_cdp_bridge_loop)
_cdp_bridge_server = CDPBridgeServer(host=host, port=port) # type: ignore[misc]
async def run() -> None:
await _cdp_bridge_server.start() # type: ignore[union-attr]
while True:
await asyncio.sleep(1)
try:
_cdp_bridge_loop.run_until_complete(run())
except Exception as e:
logger.error("CDP bridge server crashed: %s", e, exc_info=True)
def start_cdp_bridge(host: str = "localhost", port: int = 9223) -> bool:
"""Start the legacy CDP bridge server (opt-in fallback transport).
Enables HTTP communication between hanzo-mcp's tools (port 9224) and
the Hanzo browser extension (WebSocket on `port`, default 9223).
Set ``HANZO_CDP_BRIDGE_DISABLED=1`` to refuse to start.
"""
global _cdp_bridge_thread
if os.environ.get("HANZO_CDP_BRIDGE_DISABLED", "").lower() in ("1", "true", "yes"):
return False
if not CDP_BRIDGE_AVAILABLE:
return False
if _cdp_bridge_thread is not None and _cdp_bridge_thread.is_alive():
return True
host = os.environ.get("HANZO_CDP_BRIDGE_HOST", host)
port = int(os.environ.get("HANZO_CDP_BRIDGE_PORT", str(port)))
try:
_cdp_bridge_thread = threading.Thread(
target=_run_cdp_bridge_server,
args=(host, port),
daemon=True,
name="cdp-bridge-server",
)
_cdp_bridge_thread.start()
logger.info("CDP bridge started on ws://%s:%d", host, port)
return True
except Exception as e:
logger.warning("Failed to start CDP bridge: %s", e)
return False
def stop_cdp_bridge() -> None:
"""Stop the CDP bridge server."""
global _cdp_bridge_server, _cdp_bridge_thread, _cdp_bridge_loop
if _cdp_bridge_loop is not None and _cdp_bridge_server is not None:
try:
asyncio.run_coroutine_threadsafe(
_cdp_bridge_server.stop(), _cdp_bridge_loop
)
except Exception:
pass
_cdp_bridge_server = None
_cdp_bridge_thread = None
_cdp_bridge_loop = None
__all__ = [
"CDP_BRIDGE_AVAILABLE",
"ensure_zap_server",
"stop_zap_server",
"start_cdp_bridge",
"stop_cdp_bridge",
]
@@ -0,0 +1,61 @@
"""Playwright-pinned browser tool.
Same action surface as ``BrowserTool`` but with ``backend="playwright"``
forced — never dispatches through the extension or CDP bridge. Use this
when you want deterministic headless automation regardless of whether a
browser extension is connected to this MCP.
The high-level ``browser`` tool auto-detects transport. Calling
``playwright`` is the explicit "I want Playwright, period" path.
"""
from __future__ import annotations
import logging
from typing import Optional
from hanzo_tools.browser.browser_tool import BrowserTool
logger = logging.getLogger(__name__)
class PlaywrightTool(BrowserTool):
"""Browser automation pinned to the Playwright backend.
Skips the in-process ZAP server (extension dispatch) and the legacy
CDP HTTP bridge entirely — every action goes through Playwright's
Chromium driver. Suitable for headless test runs, CI pipelines, and
any context where attaching to a user-controlled browser is wrong.
The action surface (navigate, click, fill, screenshot, expect_*, ...)
is identical to ``BrowserTool`` — only the transport pin differs.
"""
name = "playwright"
def __init__(
self,
headless: bool = True,
cdp_endpoint: Optional[str] = None,
):
# Force backend="playwright" — overrides BROWSER_BACKEND env, ignores
# extension config, skips ZAP/CDP-bridge lifecycle bootstrap.
super().__init__(
headless=headless,
cdp_endpoint=cdp_endpoint,
backend="playwright",
)
@property
def description(self) -> str:
return """Playwright-pinned browser automation — peer of `browser` and `cdp`.
Same action surface as `browser` but forces backend="playwright":
- No extension dispatch (does not talk to Hanzo browser extension).
- No legacy CDP HTTP bridge.
- Pure async-playwright, headless by default.
Use `browser` for auto-routing (extension > CDP-bridge > Playwright).
Use `cdp` for raw Chrome DevTools Protocol method dispatch.
Use `playwright` for deterministic headless automation.
""" + BrowserTool.description.fget(self).split("CATEGORIES:", 1)[-1].rstrip()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-browser"
version = "0.5.5"
version = "0.5.6"
description = "Browser automation tools with ZAP-native extension routing — Python MCP hosts the ZAP server on an OS-assigned port and advertises via mDNS (HIP-0069). No node bridge, no port pool, no lockfile."
readme = "README.md"
requires-python = ">=3.12"