feat(tools-browser): ZAP-native 2-process architecture (0.5.0)

Each hanzo-mcp now hosts a ZAP server directly on the lowest free port
from [9999..9995], using a POSIX flock for cross-MCP arbitration. The
browser extension discovers and connects to this server without any
node-side bridge in the critical path.

- New hanzo_tools/browser/zap_server.py: wire format, server, lifecycle,
  client registry, leases, cluster-visibility config registry.
- _extension_command tries ZAP first (sub-1ms median round-trip on
  loopback), falls back to legacy HTTP bridge on :9224 only when no
  ZAP-connected client matches. Pinnable via BROWSER_TRANSPORT.
- New tool actions: list_mcp_instances, claim_browser, release_browser
  for sub-agent coordination across multiple parallel MCPs.
- Legacy CDP HTTP bridge no longer auto-starts (opt in with
  HANZO_CDP_BRIDGE_ENABLED=1). It remains available for non-ZAP MCP
  clients.

Tests: 30 new unit tests cover wire format, port arbitration,
multi-MCP coexistence, lease semantics, and cluster registry.
Bench shows 0.35ms median round-trip on local loopback.

Total tests: 4 baseline -> 35 passing (30 zap_server + 1 bench + 4 imports).
This commit is contained in:
2026-05-07 21:32:43 -07:00
parent 00b8c911ea
commit 90e6e4f0d9
7 changed files with 1738 additions and 58 deletions
+43
View File
@@ -95,6 +95,49 @@ SQLite-based with optional vector search (sqlite-vec).
**Backends:** local (default), sqlite, lancedb (optional), kuzudb (optional)
## Browser Tool (hanzo-tools-browser)
Two-process architecture (since 0.5.0): `hanzo-tools-browser` hosts the
ZAP server directly inside the MCP process. Browser extensions discover
it on the lowest free port from `[9999, 9998, 9997, 9996, 9995]` (POSIX
flock ensures multi-MCP coexistence under one ext).
```python
from hanzo_tools.browser import (
BrowserTool, # the MCP tool
ZapServer, # raw server (for advanced use)
get_or_start_server, # bootstrap + return singleton
get_server, # return current singleton or None
)
```
Key files:
- `hanzo_tools/browser/zap_server.py` — wire format, server, leases,
cluster registry (`~/.hanzo/extension/config.json:mcp_instances`).
- `hanzo_tools/browser/browser_tool.py` — `_extension_command` tries
ZAP first, falls back to legacy HTTP bridge on `:9224`. New actions
`list_mcp_instances`, `claim_browser`, `release_browser`.
- `hanzo_tools/browser/cdp_bridge_server.py` — legacy node-bridge
replacement (kept for non-ZAP callers).
Env vars:
- `BROWSER_TRANSPORT=zap|http|auto` — pin transport (default auto).
- `HANZO_ZAP_DISABLED=1` — don't auto-start the ZAP server.
- `HANZO_ZAP_PORTS=9999,9998` — override the candidate port list.
- `HANZO_AGENT_LABEL=...` — attach to the cluster registry entry.
- `HANZO_CDP_BRIDGE_ENABLED=1` — opt back into the legacy HTTP bridge.
Tests live in `pkg/hanzo-tools-browser/tests/test_zap_server.py` (30
cases: wire format, lifecycle, RPC, leases, multi-MCP). Latency bench
in `test_zap_bench.py`. The full suite runs with:
```bash
cd pkg/hanzo-tools-browser
uv venv .venv --python 3.12
.venv/bin/python -m pip install -e .
.venv/bin/python -m pytest tests/ -v
```
## API Tool
Generic REST API tool with OpenAPI specs.
@@ -1,12 +1,16 @@
"""Browser automation tools for Hanzo AI.
High-performance async browser automation using Playwright.
Supports shared browser instance for low latency and cross-MCP sharing via CDP.
Two-process architecture (since 0.5.0):
Integration with Hanzo Browser Extension:
The CDP bridge server starts automatically when browser tools are registered.
The browser extension connects to ws://localhost:9223/cdp automatically.
No manual setup required - just install the extension and use hanzo-mcp.
[Browser ext] --ZAP/WS--> [hanzo-mcp (this Python pkg)] --stdio--> [Agent]
The hanzo-mcp process hosts a ZAP server on the lowest free port in
[9999, 9998, 9997, 9996, 9995]. Browser extensions discover and connect to
it directly. No node bridge is in the critical path.
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``.
"""
import os
@@ -29,10 +33,18 @@ from hanzo_tools.browser.browser_tool import (
create_browser_tool,
launch_browser_server,
)
from hanzo_tools.browser.zap_server import (
DEFAULT_ZAP_PORTS,
ZapClient,
ZapServer,
get_or_start_server,
get_server,
shutdown_server,
)
logger = logging.getLogger(__name__)
# CDP Bridge for browser extension integration
# CDP Bridge for browser extension integration (legacy, opt-in)
try:
from hanzo_tools.browser.cdp_bridge_server import (
WEBSOCKETS_AVAILABLE as CDP_BRIDGE_AVAILABLE,
@@ -44,11 +56,16 @@ except ImportError:
CDPBridgeServer = None
CDPBridgeClient = None
# Global CDP bridge server instance (singleton)
# 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
# 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
# Tools list for entry point discovery
TOOLS = [BrowserTool]
@@ -60,7 +77,14 @@ __all__ = [
# Browser pool
"BrowserPool",
"launch_browser_server",
# CDP Bridge (for browser extension integration)
# ZAP (canonical, since 0.5.0)
"ZapServer",
"ZapClient",
"DEFAULT_ZAP_PORTS",
"get_or_start_server",
"get_server",
"shutdown_server",
# CDP Bridge (legacy fallback)
"CDPBridgeServer",
"CDPBridgeClient",
"CDP_BRIDGE_AVAILABLE",
@@ -75,6 +99,86 @@ __all__ = [
]
def _run_zap_server(host: str, ports: list[int]) -> 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,
ports=ports,
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")
ports_env = os.environ.get("HANZO_ZAP_PORTS")
if ports_env:
ports = [int(p) for p in ports_env.split(",") if p.strip().isdigit()]
else:
ports = list(DEFAULT_ZAP_PORTS)
_zap_started_event = threading.Event()
_zap_thread = threading.Thread(
target=_run_zap_server,
args=(host, ports),
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
@@ -170,13 +274,14 @@ def stop_cdp_bridge() -> None:
def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""Register browser tools with the MCP server.
This also auto-starts the CDP bridge server for browser extension
integration. The extension can connect to ws://localhost:9223/cdp
and control browser tabs.
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, cdp_bridge)
**kwargs: Additional arguments (headless, cdp_endpoint, backend, cdp_bridge)
Returns:
List of registered tools
@@ -185,10 +290,16 @@ def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
cdp_endpoint = kwargs.get("cdp_endpoint")
backend = kwargs.get("backend")
# Auto-start CDP bridge for browser extension integration
# (not needed when backend is explicitly "playwright")
auto_cdp_bridge = kwargs.get("cdp_bridge", True)
if auto_cdp_bridge and CDP_BRIDGE_AVAILABLE and backend != "playwright":
# 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)
@@ -102,12 +102,36 @@ def get_backend() -> str:
return "auto"
def _normalize_tab_id(tab_id: Union[str, int, None]) -> Union[str, int, None]:
"""Strip ``tab-`` prefix and coerce to int when possible."""
if tab_id is None:
return None
t = tab_id
if isinstance(t, str) and t.startswith("tab-"):
t = t[4:]
try:
return int(t)
except (TypeError, ValueError):
return t
async def _check_extension(browser: Optional[str] = None) -> bool:
"""Check if Hanzo browser extension is connected.
Args:
browser: Optional browser filter ("firefox", "chrome").
Tries the local ZAP server first (in-process, microseconds), falls back
to the legacy HTTP bridge on :9224.
"""
# 1) ZAP — if our own MCP holds an extension client locally
try:
from hanzo_tools.browser.zap_server import get_server
srv = get_server()
if srv is not None and srv.has_client(browser=browser):
return True
except Exception:
pass
# 2) Legacy HTTP bridge
try:
import aiohttp
@@ -120,7 +144,6 @@ async def _check_extension(browser: Optional[str] = None) -> bool:
if not data.get("connected", False):
return False
if browser:
# Check if requested browser is among connected clients
clients = data.get("client_list", [])
return any(
browser.lower() in c.get("browser", "").lower()
@@ -132,6 +155,79 @@ async def _check_extension(browser: Optional[str] = None) -> bool:
return False
def _zap_method_for(action: str) -> str:
"""Map a browser-tool action onto the wire method the extension expects.
The Firefox/Chrome backgrounds dispatch CDP-style methods (``Page.navigate``,
``Runtime.evaluate``, ``hanzo.click`` …) when the field is ``method`` on
the JSON it receives. Over ZAP we send the same method name so the
extension handler is shared.
"""
return {
"navigate": "Page.navigate",
"screenshot": "hanzo.screenshot",
"click": "hanzo.click",
"fill": "hanzo.fill",
"evaluate": "Runtime.evaluate",
"tabs": "Target.getTargets",
"status": "Browser.getVersion",
"url": "hanzo.url",
"title": "hanzo.title",
"reload": "Page.reload",
"go_back": "Page.goBack",
"go_forward": "Page.goForward",
"tab_info": "hanzo.tabInfo",
}.get(action, action)
def _zap_params(
action: str,
*,
tab_id: Union[str, int, None] = None,
url: Optional[str] = None,
selector: Optional[str] = None,
text: Optional[str] = None,
value: Optional[str] = None,
code: Optional[str] = None,
expression: Optional[str] = None,
full_page: Optional[bool] = None,
**rest,
) -> dict:
"""Translate the extension-tool kwargs into wire params."""
params: dict[str, Any] = {}
if url is not None:
params["url"] = url
if selector is not None:
params["selector"] = selector
if value is not None:
params["value"] = value
if text is not None:
params["text"] = text
expr = code or expression
if expr is not None:
params["expression"] = expr
if full_page is not None:
params["fullPage"] = full_page
norm_tab = _normalize_tab_id(tab_id)
if norm_tab is not None:
params["tabId"] = norm_tab
# Forward any extra kwargs the caller passed (action-specific fields).
for k, v in rest.items():
if v is None:
continue
if k in {
"key",
"index",
"tab_index",
"timeout",
"state",
"level",
"expression",
}:
params[k] = v
return params
async def _extension_command(
action: str,
browser: Optional[str] = None,
@@ -141,40 +237,73 @@ async def _extension_command(
) -> Optional[dict]:
"""Send command to Hanzo browser extension.
Args:
action: The action to perform.
browser: Optional browser preference ("firefox", "chrome"). Picks which
connected provider to dispatch the command to. With multiple
browsers logged in via the extension this is how you target a
specific one. ``None`` lets the bridge pick the active provider.
tab_id: Optional tab identifier. Accepts the ``targetId`` returned by
the ``tabs`` action (e.g. ``"tab-1888868904"``) or a raw numeric
tab id. Forwarded as ``tabId`` to the bridge so subsequent
navigate/click/screenshot/etc operate on that tab specifically
rather than the OS-active tab. Critical when the user has many
browser windows open and the wrong one is in focus.
client_id: Optional bridge client_id (returned in ``status``). Lets you
address one specific connected extension instance when the same
user has both Chrome and Firefox bridged to the same bridge.
**kwargs: Additional parameters forwarded as JSON body fields.
Path order:
1. Local in-process ZAP server (microsecond round-trip; preferred).
2. Legacy HTTP bridge on :9224 (kept as fallback for non-ZAP MCP clients).
The transport can be pinned with ``BROWSER_TRANSPORT=zap|http|auto`` —
default is ``auto`` which means "ZAP if a connected extension matches,
else HTTP".
"""
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):
method = _zap_method_for(action)
params = _zap_params(action, tab_id=tab_id, **kwargs)
try:
raw = await srv.send(
method,
params,
browser=browser,
client_id=client_id,
)
except Exception as e:
if transport == "zap":
return {"error": str(e), "transport": "zap"}
logger.debug("zap dispatch failed, falling back to http: %s", e)
else:
# CDP Runtime.evaluate returns {result: {type, value}};
# unwrap so the caller sees the value directly.
result = raw
if method == "Runtime.evaluate" and isinstance(raw, dict):
cdp = raw.get("result", raw)
if isinstance(cdp, dict) and "value" in cdp:
result = cdp["value"]
elif isinstance(cdp, dict) and cdp.get("type") == "undefined":
result = None
return {
"success": True,
"transport": "zap",
"result": result,
}
except ImportError:
# zap_server module unavailable — only HTTP path remains.
pass
if transport == "zap":
return {
"error": "ZAP transport selected but no extension client matched",
"transport": "zap",
}
# ---- 2) HTTP fallback ---------------------------------------------
try:
import aiohttp
# Filter out None values
payload = {"action": action}
payload: dict[str, Any] = {"action": action}
if browser:
payload["browser"] = browser
if tab_id is not None:
# Bridge accepts numeric tabId or string targetId. Strip the
# "tab-" prefix so the extension's chrome.tabs.get() finds it.
t = tab_id
if isinstance(t, str) and t.startswith("tab-"):
t = t[4:]
try:
payload["tabId"] = int(t)
except (TypeError, ValueError):
payload["tabId"] = t
norm_tab = _normalize_tab_id(tab_id)
if norm_tab is not None:
payload["tabId"] = norm_tab
if client_id:
payload["clientId"] = client_id
payload.update({k: v for k, v in kwargs.items() if v is not None})
@@ -186,11 +315,13 @@ async def _extension_command(
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status == 200:
return await resp.json()
body = await resp.json()
body.setdefault("transport", "http")
return body
else:
body = await resp.text()
logger.debug(f"Extension command {action} returned {resp.status}: {body}")
return {"error": body, "status": resp.status}
text = await resp.text()
logger.debug(f"Extension command {action} returned {resp.status}: {text}")
return {"error": text, "status": resp.status, "transport": "http"}
except Exception as e:
logger.debug(f"Extension command failed: {e}")
return None
@@ -396,6 +527,9 @@ Action = Annotated[
"list_browsers", # list every connected extension provider
"set_default_browser", # persist the bridge's default browser pick
"use_browser", # alias of set_default_browser
"list_mcp_instances", # list all hanzo-mcps registered with the extension
"claim_browser", # take an exclusive lease on a browser for N seconds
"release_browser", # drop a previously-claimed lease
"connect", # connect via CDP
"set_headless", # toggle headless/headed
"status", # get browser status
@@ -706,10 +840,32 @@ class BrowserTool(BaseTool):
self.backend = backend or get_backend()
self.timeout = 30000
# Auto-start CDP bridge for browser extension integration.
# This enables the extension (Chrome/Firefox/Safari) to connect
# and be preferred over Playwright when backend != "playwright".
if self.backend != "playwright":
# 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"):
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
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)
# 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:
@@ -865,6 +1021,65 @@ CATEGORIES:
timeout = timeout or self.timeout
sel = selector or ref
# === LOCAL ACTIONS (handled in-process, no extension/Playwright) ===
if action == "list_mcp_instances":
try:
from hanzo_tools.browser.zap_server import ZapServer
instances = ZapServer.list_mcp_instances()
return {
"success": True,
"mcp_instances": instances,
"count": len(instances),
}
except Exception as e:
return {"error": f"failed to list mcp instances: {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"}
ttl = float(timeout) / 1000 if timeout else DEFAULT_LEASE_TTL
lease = srv.claim(client.client_id, ttl=ttl)
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"}
# Without an explicit client_id, drop every lease this MCP holds.
if client_id:
released = srv.release(client_id)
return {"success": released, "client_id": client_id}
released_all = []
for c in list(srv.clients):
if srv.release(c.client_id):
released_all.append(c.client_id)
return {"success": True, "released": released_all}
except Exception as e:
return {"error": str(e)}
# === BACKEND-AWARE ROUTING ===
# Actions supported by the CDP bridge / browser extension
extension_actions = {
@@ -0,0 +1,712 @@
"""ZAP (Zero-latency Agent Protocol) server for hanzo-tools-browser.
Wire format (compatible with @hanzo/browser-extension/src/shared/zap.ts):
[0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON payload]
Message types:
MSG_HANDSHAKE = 0x01 client -> server
MSG_HANDSHAKE_OK = 0x02 server -> client
MSG_REQUEST = 0x10 bidirectional
MSG_RESPONSE = 0x11 bidirectional
MSG_PING = 0xFE
MSG_PONG = 0xFF
Each hanzo-mcp process runs ONE ZapServer that:
1. Claims the lowest free port from [9999, 9998, 9997, 9996, 9995] using a
POSIX flock at ~/.hanzo/extension/zap-{port}.lock so multiple MCPs don't
collide.
2. Accepts WebSocket connections from browser extensions, registers them by
{client_id, browser, version, capabilities, ws}.
3. Exposes ``send(method, params, browser=None, client_id=None)`` for the
browser tool to dispatch RPC calls into a connected extension.
4. Persists its presence to ~/.hanzo/extension/config.json:mcp_instances so
sibling MCPs can introspect the cluster.
"""
from __future__ import annotations
import asyncio
import errno
import fcntl
import json
import logging
import os
import socket
import struct
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Optional
logger = logging.getLogger(__name__)
# Wire format constants (canonical: shared/zap.ts)
ZAP_MAGIC = b"\x5a\x41\x50\x01"
HEADER_SIZE = 9 # 4 magic + 1 type + 4 length
MSG_HANDSHAKE = 0x01
MSG_HANDSHAKE_OK = 0x02
MSG_REQUEST = 0x10
MSG_RESPONSE = 0x11
MSG_PING = 0xFE
MSG_PONG = 0xFF
# Port preference (lowest first; matches DEFAULT_ZAP_PORTS in shared/zap.ts)
DEFAULT_ZAP_PORTS: list[int] = [9999, 9998, 9997, 9996, 9995]
# Browser-resolution preference (mirror cdp-bridge-server.ts)
DEFAULT_BROWSER_PREFERENCE: list[str] = ["firefox", "safari", "edge", "chrome"]
# How long an exclusive browser lease lasts unless explicitly extended.
DEFAULT_LEASE_TTL = 60.0
# ---------------------------------------------------------------------------
# Wire format
# ---------------------------------------------------------------------------
def encode(msg_type: int, payload: Any) -> bytes:
"""Encode a ZAP frame.
JSON-encoded payload, length-prefixed, big-endian.
"""
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return ZAP_MAGIC + bytes([msg_type]) + struct.pack(">I", len(body)) + body
def decode(data: bytes) -> Optional[tuple[int, Any]]:
"""Decode a single ZAP frame. Returns ``None`` for malformed input.
Caller must hand a complete frame (websockets.recv() guarantees this for
binary messages — frame == one ws message).
"""
if len(data) < HEADER_SIZE:
return None
if data[0:4] != ZAP_MAGIC:
return None
msg_type = data[4]
(length,) = struct.unpack(">I", data[5:9])
if len(data) < HEADER_SIZE + length:
return None
body = data[HEADER_SIZE : HEADER_SIZE + length]
payload = json.loads(body.decode("utf-8")) if length else None
return msg_type, payload
# ---------------------------------------------------------------------------
# Lock file (cross-MCP port arbitration)
# ---------------------------------------------------------------------------
def _lock_dir() -> Path:
d = Path.home() / ".hanzo" / "extension"
d.mkdir(parents=True, exist_ok=True)
return d
def _claim_port_lock(port: int) -> Optional[int]:
"""Try to acquire an exclusive flock on ``zap-{port}.lock``.
Returns the open file descriptor on success, ``None`` if another
process already holds it. The fd must be kept open for the lifetime
of the server (closing releases the lock).
"""
lock_path = _lock_dir() / f"zap-{port}.lock"
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
os.close(fd)
if e.errno in (errno.EWOULDBLOCK, errno.EACCES):
return None
raise
# Write our pid so the file is informative
os.ftruncate(fd, 0)
os.write(fd, f"{os.getpid()}\n".encode("utf-8"))
return fd
def _release_port_lock(fd: Optional[int]) -> None:
if fd is not None:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
pass
try:
os.close(fd)
except OSError:
pass
def _port_is_free(port: int, host: str = "127.0.0.1") -> bool:
"""Best-effort TCP probe — does a bind succeed?"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind((host, port))
s.close()
return True
except OSError:
s.close()
return False
# ---------------------------------------------------------------------------
# Shared config (~/.hanzo/extension/config.json)
# ---------------------------------------------------------------------------
def _config_path() -> Path:
return _lock_dir() / "config.json"
def _config_lock_path() -> Path:
return _lock_dir() / "config.json.lock"
def _read_config() -> dict:
p = _config_path()
if not p.exists():
return {}
try:
return json.loads(p.read_text())
except Exception:
return {}
def _atomic_update_config(mutate: Callable[[dict], None]) -> dict:
"""Read-modify-write ~/.hanzo/extension/config.json under fcntl flock."""
lock_path = _config_lock_path()
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
cfg = _read_config()
mutate(cfg)
tmp = _config_path().with_suffix(".json.tmp")
tmp.write_text(json.dumps(cfg, indent=2))
os.replace(tmp, _config_path())
return cfg
finally:
try:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
except OSError:
pass
os.close(lock_fd)
# ---------------------------------------------------------------------------
# Client tracking
# ---------------------------------------------------------------------------
@dataclass
class ZapClient:
"""A connected browser extension."""
client_id: str
browser: str
version: str
capabilities: list[str]
ws: Any # websockets.WebSocketServerProtocol — typed Any to avoid hard dep
connected_at: float = field(default_factory=time.time)
last_active: float = field(default_factory=time.time)
def to_dict(self) -> dict:
return {
"client_id": self.client_id,
"browser": self.browser,
"version": self.version,
"capabilities": self.capabilities,
"connected_at": self.connected_at,
"last_active": self.last_active,
}
@dataclass
class BrowserLease:
"""An exclusive lease on a browser client (sub-agent claim/release)."""
client_id: str
holder: str
expires_at: float
@property
def expired(self) -> bool:
return time.time() >= self.expires_at
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
class ZapServer:
"""Single-port ZAP server hosted inside a hanzo-mcp process.
One instance per Python MCP. Lifetime = MCP lifetime. Concurrent
extensions register and dispatch independently.
"""
def __init__(
self,
host: str = "127.0.0.1",
ports: Optional[list[int]] = None,
agent_label: Optional[str] = None,
server_id: Optional[str] = None,
request_timeout: float = 30.0,
):
self.host = host
self.ports = list(ports or DEFAULT_ZAP_PORTS)
self.agent_label = agent_label or os.environ.get("HANZO_AGENT_LABEL", "")
self.server_id = server_id or f"mcp-py-{os.getpid()}"
self.request_timeout = request_timeout
self._port: Optional[int] = None
self._lock_fd: Optional[int] = None
self._server: Any = None # websockets server
self._clients: dict[str, ZapClient] = {}
self._ws_to_id: dict[Any, str] = {}
self._pending: dict[str, asyncio.Future] = {}
self._req_counter = 0
self._leases: dict[str, BrowserLease] = {} # client_id -> lease
self._tools_manifest: list[dict] = [
{
"name": "browser",
"description": "Hanzo browser tool (Python MCP, ZAP-native)",
"inputSchema": {"type": "object"},
}
]
# ---- lifecycle ------------------------------------------------------
async def start(self) -> Optional[int]:
"""Bind to the lowest-numbered free port. Returns port or None."""
try:
import websockets # noqa: F401
except ImportError:
logger.warning("websockets not installed; ZAP server disabled")
return None
for port in self.ports:
fd = _claim_port_lock(port)
if fd is None:
logger.debug("zap port %d already locked by another mcp", port)
continue
if not _port_is_free(port, self.host):
_release_port_lock(fd)
logger.debug("zap port %d in use (no lock but socket bound)", port)
continue
try:
from websockets.asyncio.server import serve as _serve
async def _handler(websocket):
await self._handle_connection(websocket)
self._server = await _serve(_handler, self.host, port)
except ImportError:
# Older websockets API (< 13)
from websockets.server import serve as _serve # type: ignore
async def _handler_legacy(websocket, _path):
await self._handle_connection(websocket)
self._server = await _serve(_handler_legacy, self.host, port) # type: ignore
except OSError as e:
_release_port_lock(fd)
logger.debug("zap bind on %d failed: %s", port, e)
continue
self._port = port
self._lock_fd = fd
self._register_in_config()
logger.info(
"ZAP server listening on ws://%s:%d (mcp=%s, agent=%s)",
self.host,
port,
self.server_id,
self.agent_label or "?",
)
return port
logger.warning("ZAP: no free port in %s", self.ports)
return None
async def stop(self) -> None:
"""Gracefully shut down: close clients, release lock, drop registry entry."""
if self._server is not None:
self._server.close()
try:
await self._server.wait_closed()
except Exception:
pass
self._server = None
for client in list(self._clients.values()):
try:
await client.ws.close()
except Exception:
pass
self._clients.clear()
self._ws_to_id.clear()
self._unregister_from_config()
_release_port_lock(self._lock_fd)
self._lock_fd = None
self._port = None
# ---- public API -----------------------------------------------------
@property
def port(self) -> Optional[int]:
return self._port
@property
def clients(self) -> list[ZapClient]:
return list(self._clients.values())
def has_client(self, browser: Optional[str] = None) -> bool:
if not self._clients:
return False
if not browser:
return True
b = browser.lower()
return any(b in c.browser.lower() for c in self._clients.values())
def resolve_client(
self,
client_id: Optional[str] = None,
browser: Optional[str] = None,
) -> Optional[ZapClient]:
"""Pick which connected extension to dispatch to.
Priority: explicit client_id > browser preference > most-recent-active
> default browser preference list.
"""
if client_id and client_id in self._clients:
return self._clients[client_id]
candidates = list(self._clients.values())
if not candidates:
return None
if browser:
b = browser.lower()
matches = [c for c in candidates if b in c.browser.lower()]
if not matches:
return None
return max(matches, key=lambda c: c.last_active)
# No explicit selector: respect global default preference list.
for pref in DEFAULT_BROWSER_PREFERENCE:
matches = [c for c in candidates if pref in c.browser.lower()]
if matches:
return max(matches, key=lambda c: c.last_active)
return max(candidates, key=lambda c: c.last_active)
async def send(
self,
method: str,
params: Optional[dict] = None,
*,
browser: Optional[str] = None,
client_id: Optional[str] = None,
timeout: Optional[float] = None,
) -> Any:
"""Send a method request to a connected extension and await result.
Raises ``RuntimeError`` if no client matches.
"""
client = self.resolve_client(client_id=client_id, browser=browser)
if client is None:
raise RuntimeError(
f"No ZAP-connected browser extension"
+ (f" matching '{browser}'" if browser else "")
)
# Honour leases: if a different holder has a non-expired lease on this
# client, reject.
lease = self._leases.get(client.client_id)
if lease and not lease.expired and lease.holder != self.server_id:
raise RuntimeError(
f"browser leased by {lease.holder} until {time.ctime(lease.expires_at)}"
)
req_id = self._next_req_id()
future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[req_id] = future
client.last_active = time.time()
try:
await client.ws.send(
encode(MSG_REQUEST, {"id": req_id, "method": method, "params": params or {}})
)
return await asyncio.wait_for(future, timeout=timeout or self.request_timeout)
finally:
self._pending.pop(req_id, None)
# ---- leases ---------------------------------------------------------
def claim(self, client_id: str, ttl: float = DEFAULT_LEASE_TTL) -> BrowserLease:
"""Take an exclusive lease on a browser client for ``ttl`` seconds.
Raises ``RuntimeError`` if already held by someone else.
"""
lease = self._leases.get(client_id)
if lease and not lease.expired and lease.holder != self.server_id:
raise RuntimeError(
f"already leased by {lease.holder} until {time.ctime(lease.expires_at)}"
)
new = BrowserLease(
client_id=client_id,
holder=self.server_id,
expires_at=time.time() + ttl,
)
self._leases[client_id] = new
return new
def release(self, client_id: str) -> bool:
"""Release a lease this server holds. Returns True if released."""
lease = self._leases.get(client_id)
if lease and lease.holder == self.server_id:
del self._leases[client_id]
return True
return False
# ---- registry (cross-MCP visibility) -------------------------------
def _register_in_config(self) -> None:
def mut(cfg: dict) -> None:
instances = cfg.setdefault("mcp_instances", [])
# purge stale (dead pids) and any prior entry for our own
# (pid, port). In production each MCP is its own process so
# de-dup by pid alone would suffice; we key on (pid, port) so
# tests and edge cases that bring up two ZAP servers under one
# pid both stay visible.
instances[:] = [
i
for i in instances
if _pid_alive(i.get("pid"))
and not (i.get("pid") == os.getpid() and i.get("port") == self._port)
]
instances.append(
{
"port": self._port,
"pid": os.getpid(),
"started_at": time.time(),
"agent_label": self.agent_label,
"server_id": self.server_id,
}
)
try:
_atomic_update_config(mut)
except Exception as e:
logger.warning("config registry write failed: %s", e)
def _unregister_from_config(self) -> None:
target_pid = os.getpid()
target_port = self._port
def mut(cfg: dict) -> None:
instances = cfg.get("mcp_instances", [])
cfg["mcp_instances"] = [
i
for i in instances
if not (i.get("pid") == target_pid and i.get("port") == target_port)
]
try:
_atomic_update_config(mut)
except Exception:
pass
@staticmethod
def list_mcp_instances() -> list[dict]:
"""Read the cluster registry (filters dead pids)."""
cfg = _read_config()
instances = cfg.get("mcp_instances", [])
return [i for i in instances if _pid_alive(i.get("pid"))]
# ---- ws handler -----------------------------------------------------
async def _handle_connection(self, websocket: Any) -> None:
try:
async for raw in websocket:
if not isinstance(raw, (bytes, bytearray)):
# Spec is binary frames; ignore stray text.
continue
decoded = decode(bytes(raw))
if decoded is None:
logger.debug("zap: malformed frame from %s", websocket)
continue
msg_type, payload = decoded
await self._dispatch(websocket, msg_type, payload or {})
except Exception as e:
# websockets normalises connection-closed via exception flow;
# don't spam logs.
logger.debug("zap connection ended: %s", e)
finally:
cid = self._ws_to_id.pop(websocket, None)
if cid:
self._clients.pop(cid, None)
self._leases.pop(cid, None)
logger.info("zap: client disconnected %s", cid)
async def _dispatch(self, websocket: Any, msg_type: int, payload: dict) -> None:
if msg_type == MSG_HANDSHAKE:
client_id = payload.get("clientId") or f"ext-{int(time.time() * 1000)}"
client = ZapClient(
client_id=client_id,
browser=payload.get("browser", "unknown"),
version=payload.get("version", "0"),
capabilities=list(payload.get("capabilities") or []),
ws=websocket,
)
self._clients[client_id] = client
self._ws_to_id[websocket] = client_id
logger.info(
"zap: client connected %s (%s v%s, %d caps)",
client_id,
client.browser,
client.version,
len(client.capabilities),
)
await websocket.send(
encode(
MSG_HANDSHAKE_OK,
{
"serverId": self.server_id,
"name": "hanzo-mcp",
"agentLabel": self.agent_label,
"tools": self._tools_manifest,
},
)
)
return
if msg_type == MSG_PING:
await websocket.send(encode(MSG_PONG, {}))
return
if msg_type == MSG_PONG:
return
# MSG_RESPONSE: extension is answering an RPC we sent.
if msg_type == MSG_RESPONSE:
req_id = payload.get("id")
future = self._pending.get(req_id) if req_id else None
if future is None or future.done():
return
if "error" in payload and payload["error"]:
err = payload["error"]
msg = err.get("message") if isinstance(err, dict) else str(err)
future.set_exception(RuntimeError(msg or "ZAP error"))
else:
future.set_result(payload.get("result"))
return
# MSG_REQUEST: extension is calling US (notifications/elementSelected,
# etc.). Acknowledge so the protocol doesn't block.
if msg_type == MSG_REQUEST:
req_id = payload.get("id")
method = payload.get("method", "")
# Update last_active
cid = self._ws_to_id.get(websocket)
if cid and cid in self._clients:
self._clients[cid].last_active = time.time()
# Notifications are fire-and-forget; everything else gets a stub
# acknowledgement (caller can still install custom handlers via
# ``on_request`` if needed).
if req_id is not None:
await websocket.send(encode(MSG_RESPONSE, {"id": req_id, "result": {"ack": True, "method": method}}))
return
logger.debug("zap: unknown msg type 0x%02x", msg_type)
def _next_req_id(self) -> str:
self._req_counter += 1
return f"py-{self._req_counter}"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _pid_alive(pid: Optional[int]) -> bool:
if not pid:
return False
try:
os.kill(pid, 0)
return True
except OSError as e:
# ESRCH = no such process; EPERM = exists but not ours
return e.errno == errno.EPERM
# ---------------------------------------------------------------------------
# Process-wide singleton (one ZAP server per hanzo-mcp)
# ---------------------------------------------------------------------------
_singleton: Optional[ZapServer] = None
_singleton_lock = asyncio.Lock()
async def get_or_start_server(
*,
host: str = "127.0.0.1",
ports: Optional[list[int]] = None,
agent_label: Optional[str] = None,
) -> Optional[ZapServer]:
"""Return the process-wide ZAP server, starting it if needed.
Returns ``None`` if no port could be bound (other MCPs hold all 5).
"""
global _singleton
async with _singleton_lock:
if _singleton is not None and _singleton.port is not None:
return _singleton
srv = ZapServer(host=host, ports=ports, agent_label=agent_label)
port = await srv.start()
if port is None:
return None
_singleton = srv
return srv
def get_server() -> Optional[ZapServer]:
"""Return the current singleton (or None if not started)."""
return _singleton
async def shutdown_server() -> None:
global _singleton
async with _singleton_lock:
if _singleton is not None:
await _singleton.stop()
_singleton = None
__all__ = [
"ZapClient",
"ZapServer",
"BrowserLease",
"DEFAULT_ZAP_PORTS",
"DEFAULT_BROWSER_PREFERENCE",
"DEFAULT_LEASE_TTL",
"MSG_HANDSHAKE",
"MSG_HANDSHAKE_OK",
"MSG_REQUEST",
"MSG_RESPONSE",
"MSG_PING",
"MSG_PONG",
"ZAP_MAGIC",
"encode",
"decode",
"get_or_start_server",
"get_server",
"shutdown_server",
]
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-browser"
version = "0.4.6"
description = "Browser automation tools with extension-first routing - uses Hanzo browser extension when available, Playwright as fallback"
version = "0.5.0"
description = "Browser automation tools with ZAP-native extension routing — Python MCP hosts the ZAP server directly, no node bridge in the critical path. Playwright remains as offline fallback."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
@@ -0,0 +1,80 @@
"""Latency benchmarks for ZAP vs HTTP-bridge transports.
This isn't strict pass/fail — it asserts a generous upper bound on ZAP
median round-trip and prints both numbers for comparison so PR reviewers
can see the win.
"""
from __future__ import annotations
import asyncio
import json
import statistics
import time
from typing import Callable
import pytest
from hanzo_tools.browser import zap_server as zs
from tests.test_zap_server import MockExtensionClient, _free_ports # type: ignore
pytestmark = pytest.mark.asyncio
def _percentile(values: list[float], p: float) -> float:
if not values:
return 0.0
s = sorted(values)
idx = max(0, min(len(s) - 1, int(round(p / 100.0 * (len(s) - 1)))))
return s[idx]
async def _measure(label: str, n: int, op: Callable[[], asyncio.Future]) -> dict:
# warm-up
for _ in range(5):
await op()
samples: list[float] = []
for _ in range(n):
t0 = time.perf_counter()
await op()
samples.append((time.perf_counter() - t0) * 1000.0)
return {
"label": label,
"n": n,
"min_ms": min(samples),
"p50_ms": statistics.median(samples),
"p95_ms": _percentile(samples, 95),
"max_ms": max(samples),
}
async def test_zap_round_trip_under_5ms_median(tmp_path, monkeypatch):
"""ZAP `evaluate('1+1')` median round-trip must be sub-5ms locally."""
monkeypatch.setenv("HOME", str(tmp_path))
ports = _free_ports(5)
srv = zs.ZapServer(ports=ports)
port = await srv.start()
assert port is not None
client = MockExtensionClient(port, browser="firefox", client_id="bench")
await client.connect()
await asyncio.sleep(0.02)
# Extension responds immediately to evaluate("1+1") with the value.
client.response_handler = lambda m, p: {"result": {"type": "number", "value": 2}}
try:
result = await _measure(
"zap",
n=200,
op=lambda: srv.send("Runtime.evaluate", {"expression": "1+1"}),
)
# Print so CI logs surface the number.
print(f"\n[ZAP bench] {json.dumps(result, indent=2)}")
# Generous bound: 5ms p50 on local loopback is safe even on a
# busy laptop; production target is <1ms.
assert result["p50_ms"] < 5.0, f"ZAP p50 too high: {result}"
finally:
await client.close()
await srv.stop()
@@ -0,0 +1,519 @@
"""Tests for hanzo_tools.browser.zap_server.
Covers:
- Wire format encode/decode round-trip and parity with shared/zap.ts.
- Server bind, port-arbitration via flock (multi-MCP coexistence).
- Mock extension client: register, send response to RPC, ping/pong.
- Browser resolution priority (client_id > browser > default preference).
- Browser leases (claim / release / reject when held by other holder).
- Cluster registry in ~/.hanzo/extension/config.json.
"""
from __future__ import annotations
import asyncio
import json
import os
import struct
import time
from pathlib import Path
import pytest
import websockets
from websockets.asyncio.client import connect as ws_connect
from hanzo_tools.browser import zap_server as zs
# ---------------------------------------------------------------------------
# Wire format
# ---------------------------------------------------------------------------
class TestWireFormat:
def test_magic(self):
assert zs.ZAP_MAGIC == b"\x5a\x41\x50\x01"
def test_constants_match_extension(self):
# Must stay locked to shared/zap.ts canonical values.
assert zs.MSG_HANDSHAKE == 0x01
assert zs.MSG_HANDSHAKE_OK == 0x02
assert zs.MSG_REQUEST == 0x10
assert zs.MSG_RESPONSE == 0x11
assert zs.MSG_PING == 0xFE
assert zs.MSG_PONG == 0xFF
def test_default_ports(self):
assert zs.DEFAULT_ZAP_PORTS == [9999, 9998, 9997, 9996, 9995]
def test_encode_layout(self):
frame = zs.encode(zs.MSG_REQUEST, {"id": "x", "method": "y"})
assert frame[:4] == zs.ZAP_MAGIC
assert frame[4] == zs.MSG_REQUEST
(length,) = struct.unpack(">I", frame[5:9])
assert length == len(frame) - zs.HEADER_SIZE
assert json.loads(frame[9:].decode()) == {"id": "x", "method": "y"}
def test_round_trip(self):
for msg_type in (
zs.MSG_HANDSHAKE,
zs.MSG_HANDSHAKE_OK,
zs.MSG_REQUEST,
zs.MSG_RESPONSE,
zs.MSG_PING,
zs.MSG_PONG,
):
payload = {"a": 1, "b": [1, 2, 3], "c": {"nested": True}}
decoded = zs.decode(zs.encode(msg_type, payload))
assert decoded is not None
assert decoded[0] == msg_type
assert decoded[1] == payload
def test_decode_rejects_bad_magic(self):
bad = b"\xff\xff\xff\xff" + b"\x10" + struct.pack(">I", 0)
assert zs.decode(bad) is None
def test_decode_rejects_short_frame(self):
assert zs.decode(b"") is None
assert zs.decode(b"\x5a\x41\x50") is None
def test_decode_handles_empty_payload(self):
frame = zs.encode(zs.MSG_PING, {})
decoded = zs.decode(frame)
assert decoded is not None and decoded[1] == {}
# ---------------------------------------------------------------------------
# Helpers — mock extension client
# ---------------------------------------------------------------------------
class MockExtensionClient:
"""Minimal browser-extension client speaking ZAP wire format.
Registers on connect, exposes ``response_handler`` so tests can react
to inbound MSG_REQUEST and reply with MSG_RESPONSE.
"""
def __init__(self, port: int, *, browser: str = "firefox", client_id: str | None = None):
self.port = port
self.browser = browser
self.client_id = client_id or f"ext-test-{int(time.time() * 1000)}"
self.ws: websockets.ClientConnection | None = None
self.received: list[tuple[int, dict]] = []
self.handshake_ok: dict | None = None
self._task: asyncio.Task | None = None
self.response_handler: callable | None = None
async def connect(self) -> None:
self.ws = await ws_connect(f"ws://127.0.0.1:{self.port}")
await self.ws.send(
zs.encode(
zs.MSG_HANDSHAKE,
{
"clientId": self.client_id,
"clientType": "browser_extension",
"browser": self.browser,
"version": "test-0.0",
"capabilities": ["navigate", "evaluate", "click"],
},
)
)
# Wait for HANDSHAKE_OK
raw = await asyncio.wait_for(self.ws.recv(), timeout=2.0)
decoded = zs.decode(bytes(raw))
assert decoded is not None and decoded[0] == zs.MSG_HANDSHAKE_OK
self.handshake_ok = decoded[1]
self._task = asyncio.create_task(self._run())
async def _run(self) -> None:
assert self.ws is not None
try:
async for raw in self.ws:
if not isinstance(raw, (bytes, bytearray)):
continue
decoded = zs.decode(bytes(raw))
if decoded is None:
continue
msg_type, payload = decoded
self.received.append((msg_type, payload or {}))
if msg_type == zs.MSG_REQUEST and self.response_handler is not None:
method = payload.get("method") if payload else ""
params = payload.get("params") if payload else {}
req_id = payload.get("id") if payload else None
try:
result = self.response_handler(method, params)
if asyncio.iscoroutine(result):
result = await result
await self.ws.send(zs.encode(zs.MSG_RESPONSE, {"id": req_id, "result": result}))
except Exception as e:
await self.ws.send(
zs.encode(zs.MSG_RESPONSE, {"id": req_id, "error": {"code": -1, "message": str(e)}})
)
elif msg_type == zs.MSG_PING:
await self.ws.send(zs.encode(zs.MSG_PONG, {}))
except Exception:
pass
async def close(self) -> None:
if self._task:
self._task.cancel()
if self.ws:
await self.ws.close()
# ---------------------------------------------------------------------------
# Server fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def isolated_home(tmp_path, monkeypatch):
"""Sandbox ~/.hanzo so tests never collide with real user state."""
monkeypatch.setenv("HOME", str(tmp_path))
yield tmp_path
def _free_ports(n: int) -> list[int]:
"""Return ``n`` distinct, currently-unbound TCP ports."""
import socket as _s
ports: list[int] = []
socks: list[_s.socket] = []
try:
for _ in range(n):
s = _s.socket(_s.AF_INET, _s.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
socks.append(s)
ports.append(s.getsockname()[1])
finally:
for s in socks:
s.close()
return ports
@pytest.fixture
def free_ports():
"""5 ephemeral, currently-free ports — replaces the well-known
9999..9995 range so tests don't depend on machine state."""
return _free_ports(5)
@pytest.fixture
async def server(isolated_home, free_ports):
srv = zs.ZapServer(ports=free_ports)
port = await srv.start()
assert port is not None, "ZAP server failed to bind"
yield srv
await srv.stop()
@pytest.fixture
async def client(server):
c = MockExtensionClient(server.port)
await c.connect()
# Allow the server to register the connection
await asyncio.sleep(0.05)
yield c
await c.close()
# ---------------------------------------------------------------------------
# Bind / shutdown
# ---------------------------------------------------------------------------
class TestServerLifecycle:
async def test_binds_on_first_free_port(self, isolated_home, free_ports):
srv = zs.ZapServer(ports=free_ports)
try:
port = await srv.start()
# Must be the FIRST entry (lowest-priority) when all are free.
assert port == free_ports[0]
finally:
await srv.stop()
async def test_lock_file_created(self, isolated_home, free_ports):
srv = zs.ZapServer(ports=free_ports)
try:
port = await srv.start()
assert port is not None
lock_path = Path(isolated_home) / ".hanzo" / "extension" / f"zap-{port}.lock"
assert lock_path.exists()
finally:
await srv.stop()
async def test_two_servers_pick_different_ports(self, isolated_home, free_ports):
a = zs.ZapServer(ports=free_ports)
b = zs.ZapServer(ports=free_ports)
try:
port_a = await a.start()
port_b = await b.start()
assert port_a is not None
assert port_b is not None
assert port_a != port_b
# b should pick a strictly LOWER-priority slot than a
assert free_ports.index(port_b) > free_ports.index(port_a)
finally:
await a.stop()
await b.stop()
async def test_stop_releases_lock(self, isolated_home, free_ports):
srv = zs.ZapServer(ports=free_ports)
port = await srv.start()
assert port is not None
await srv.stop()
# Now another fresh server should be able to claim that same port
srv2 = zs.ZapServer(ports=[port])
port2 = await srv2.start()
assert port2 == port
await srv2.stop()
# ---------------------------------------------------------------------------
# Handshake / client registry
# ---------------------------------------------------------------------------
class TestClientRegistry:
async def test_handshake_registers_client(self, server, client):
assert server.has_client()
assert server.has_client(browser="firefox")
assert not server.has_client(browser="chrome")
assert len(server.clients) == 1
assert server.clients[0].browser == "firefox"
assert server.clients[0].client_id == client.client_id
async def test_handshake_ok_includes_server_id_and_tools(self, server, client):
assert client.handshake_ok is not None
assert client.handshake_ok["serverId"] == server.server_id
assert isinstance(client.handshake_ok["tools"], list)
assert any(t["name"] == "browser" for t in client.handshake_ok["tools"])
async def test_disconnect_removes_client(self, server):
c = MockExtensionClient(server.port)
await c.connect()
await asyncio.sleep(0.05)
assert server.has_client()
await c.close()
# Allow server cleanup
await asyncio.sleep(0.1)
assert not server.has_client()
# ---------------------------------------------------------------------------
# RPC dispatch
# ---------------------------------------------------------------------------
class TestRpcDispatch:
async def test_send_round_trips_to_extension(self, server, client):
async def handler(method, params):
assert method == "Page.navigate"
assert params == {"url": "https://example.com"}
return {"frameId": "main"}
client.response_handler = handler
result = await server.send("Page.navigate", {"url": "https://example.com"})
assert result == {"frameId": "main"}
async def test_extension_error_propagates(self, server, client):
async def handler(method, params):
raise RuntimeError("nope")
client.response_handler = handler
with pytest.raises(RuntimeError, match="nope"):
await server.send("hanzo.click", {"selector": "#x"})
async def test_send_with_browser_filter(self, server):
c1 = MockExtensionClient(server.port, browser="firefox", client_id="ff-1")
c2 = MockExtensionClient(server.port, browser="chrome", client_id="ch-1")
await c1.connect()
await c2.connect()
await asyncio.sleep(0.05)
c1.response_handler = lambda m, p: "from-firefox"
c2.response_handler = lambda m, p: "from-chrome"
try:
assert await server.send("any", {}, browser="firefox") == "from-firefox"
assert await server.send("any", {}, browser="chrome") == "from-chrome"
finally:
await c1.close()
await c2.close()
async def test_no_match_raises(self, server, client):
# client is firefox; ask for chrome
with pytest.raises(RuntimeError, match="No ZAP-connected"):
await server.send("any", {}, browser="chrome")
async def test_ping_pong(self, server, client):
# Send ping and verify server echoes pong; just ensure no crash.
await client.ws.send(zs.encode(zs.MSG_PING, {}))
# Receive pong
await asyncio.sleep(0.05)
# Server replies with MSG_PONG (we caught it in client.received via _run)
types = [t for t, _ in client.received]
assert zs.MSG_PONG in types
async def test_extension_initiated_request_acked(self, server, client):
# Extension sends MSG_REQUEST as a notification (server must ack)
await client.ws.send(zs.encode(zs.MSG_REQUEST, {"id": "evt-1", "method": "notifications/elementSelected", "params": {"x": 1}}))
await asyncio.sleep(0.1)
# Find the response
responses = [p for t, p in client.received if t == zs.MSG_RESPONSE]
assert any(r.get("id") == "evt-1" for r in responses)
# ---------------------------------------------------------------------------
# Resolution priority
# ---------------------------------------------------------------------------
class TestResolution:
async def test_resolve_explicit_client_id(self, server):
c1 = MockExtensionClient(server.port, browser="firefox", client_id="ff-1")
c2 = MockExtensionClient(server.port, browser="firefox", client_id="ff-2")
await c1.connect()
await c2.connect()
await asyncio.sleep(0.05)
try:
picked = server.resolve_client(client_id="ff-2")
assert picked is not None and picked.client_id == "ff-2"
finally:
await c1.close()
await c2.close()
async def test_default_prefers_firefox(self, server):
chrome = MockExtensionClient(server.port, browser="chrome", client_id="ch")
firefox = MockExtensionClient(server.port, browser="firefox", client_id="ff")
await chrome.connect()
await firefox.connect()
await asyncio.sleep(0.05)
try:
picked = server.resolve_client()
assert picked is not None and "firefox" in picked.browser
finally:
await chrome.close()
await firefox.close()
# ---------------------------------------------------------------------------
# Leases
# ---------------------------------------------------------------------------
class TestLeases:
async def test_claim_and_release(self, server, client):
srv2_id = "mcp-py-other"
lease = server.claim(client.client_id, ttl=10)
assert lease.client_id == client.client_id
assert lease.holder == server.server_id
assert server.release(client.client_id) is True
async def test_claim_rejects_when_held_by_other(self, server, client):
# First grab from server
server.claim(client.client_id, ttl=10)
# Synthesise a different "holder" by mutating server_id; this is the
# closest we can get without spinning up a second ZapServer.
original = server.server_id
try:
server.server_id = "other-mcp"
with pytest.raises(RuntimeError, match="already leased"):
server.claim(client.client_id, ttl=10)
finally:
server.server_id = original
async def test_release_only_works_for_holder(self, server, client):
server.claim(client.client_id, ttl=10)
original = server.server_id
try:
server.server_id = "other-mcp"
assert server.release(client.client_id) is False
finally:
server.server_id = original
async def test_send_blocked_by_other_holder(self, server, client):
# Hold with a different holder
from hanzo_tools.browser.zap_server import BrowserLease
server._leases[client.client_id] = BrowserLease(
client_id=client.client_id,
holder="someone-else",
expires_at=time.time() + 30,
)
with pytest.raises(RuntimeError, match="leased by"):
await server.send("any", {})
# ---------------------------------------------------------------------------
# Cluster registry
# ---------------------------------------------------------------------------
class TestClusterRegistry:
async def test_registry_includes_running_server(self, isolated_home, free_ports):
srv = zs.ZapServer(ports=free_ports, agent_label="dev-agent")
try:
await srv.start()
instances = zs.ZapServer.list_mcp_instances()
mine = [i for i in instances if i["pid"] == os.getpid()]
assert mine, "this MCP missing from registry"
assert mine[0]["agent_label"] == "dev-agent"
assert mine[0]["port"] == srv.port
finally:
await srv.stop()
async def test_unregister_on_stop(self, isolated_home, free_ports):
srv = zs.ZapServer(ports=free_ports)
port = await srv.start()
await srv.stop()
instances = zs.ZapServer.list_mcp_instances()
# The (pid, port) tuple this server occupied must be gone.
assert not any(
i.get("pid") == os.getpid() and i.get("port") == port for i in instances
)
# ---------------------------------------------------------------------------
# Multi-MCP scenario (the headline use case)
# ---------------------------------------------------------------------------
class TestMultiMcp:
async def test_two_mcps_with_one_extension(self, isolated_home, free_ports):
"""Two Python ZAP servers running side by side; one mock extension
connects to BOTH; each MCP can dispatch independently."""
srv_a = zs.ZapServer(ports=free_ports, agent_label="agent-A")
srv_b = zs.ZapServer(ports=free_ports, agent_label="agent-B")
try:
port_a = await srv_a.start()
port_b = await srv_b.start()
assert port_a is not None and port_b is not None
assert port_a != port_b
client_a = MockExtensionClient(port_a, browser="firefox", client_id="ext-shared")
client_b = MockExtensionClient(port_b, browser="firefox", client_id="ext-shared")
await client_a.connect()
await client_b.connect()
await asyncio.sleep(0.05)
client_a.response_handler = lambda m, p: {"served_by": "A"}
client_b.response_handler = lambda m, p: {"served_by": "B"}
# Dispatch from each MCP — must NOT cross-talk.
r_a = await srv_a.send("any", {})
r_b = await srv_b.send("any", {})
assert r_a == {"served_by": "A"}
assert r_b == {"served_by": "B"}
# Registry sees both
instances = zs.ZapServer.list_mcp_instances()
labels = {i.get("agent_label") for i in instances}
assert {"agent-A", "agent-B"}.issubset(labels)
await client_a.close()
await client_b.close()
finally:
await srv_a.stop()
await srv_b.stop()